From 6fdfc5f4a803b101b8c5d1fca4643a7ec5c05685 Mon Sep 17 00:00:00 2001 From: George Saliba Date: Thu, 6 Aug 2026 11:17:54 +0200 Subject: [PATCH 01/16] Search every part source at once and merge the results Adds part_search and part_fetch over ten providers, queried equally with no default and no fallback ordering. Merged hits are sorted by provider then part, which is not a relevance ranking, and each hit carries a ref of the form provider:part_id so a fetch names its source without the caller pairing an id with a provider by hand. Sources come in two kinds and the distinction is reported per hit. Library providers yield geometry that can be placed: the Altium and KiCad libraries already installed, an open registry, and the openly published KiCad libraries on GitHub. Catalogue providers yield part identity and a datasheet and no geometry at all: Digi-Key, Mouser, Nexar, element14 and TME. Learning that a distributor hit has no symbol after choosing the part is expensive, so kind travels with the result. Every catalogue endpoint was probed before being written down. Three further candidates answered 404 on the recalled URL and were dropped rather than shipped as plausible guesses. What probing could not establish without a paid credential is the response shape, so each catalogue publishes verified_live false and the parsers degrade a single hit on a renamed field instead of losing the whole search. Mouser answers an invalid API key with HTTP 200 and an error body. A client judging success by status code alone would report a rejected credential as a search that ran and matched nothing, so payload level error detection lives in the shared base rather than in five copies. No credential ships with the project. An unconfigured or rejected source raises ProviderUnavailable naming the environment variable it wants, because "the endpoint is gone" and "no such part exists" are different answers and only one is a reason to stop looking. GitHub is read through its documented API with a User-Agent naming this project, one recursive request per repository, and a week long disk cache. GitLab is left alone: its robots.txt disallows /api/v*, which is where KiCad's symbol repository lives, so symbols stay with the local reader. --- src/eda_agent/libimport/providers/__init__.py | 241 ++++ .../libimport/providers/_distributor.py | 259 +++++ src/eda_agent/libimport/providers/_http.py | 141 +++ .../libimport/providers/altium_local.py | 162 +++ src/eda_agent/libimport/providers/base.py | 145 +++ .../libimport/providers/distributors.py | 392 +++++++ src/eda_agent/libimport/providers/easyeda.py | 86 ++ .../libimport/providers/kicad_local.py | 400 +++++++ src/eda_agent/libimport/providers/partreel.py | 301 +++++ .../libimport/providers/public_libraries.py | 280 +++++ src/eda_agent/tools/parts.py | 247 ++++ tests/test_altium_local_provider.py | 139 +++ tests/test_distributor_providers.py | 377 ++++++ tests/test_part_providers.py | 1012 +++++++++++++++++ tests/test_public_libraries_provider.py | 273 +++++ 15 files changed, 4455 insertions(+) create mode 100644 src/eda_agent/libimport/providers/__init__.py create mode 100644 src/eda_agent/libimport/providers/_distributor.py create mode 100644 src/eda_agent/libimport/providers/_http.py create mode 100644 src/eda_agent/libimport/providers/altium_local.py create mode 100644 src/eda_agent/libimport/providers/base.py create mode 100644 src/eda_agent/libimport/providers/distributors.py create mode 100644 src/eda_agent/libimport/providers/easyeda.py create mode 100644 src/eda_agent/libimport/providers/kicad_local.py create mode 100644 src/eda_agent/libimport/providers/partreel.py create mode 100644 src/eda_agent/libimport/providers/public_libraries.py create mode 100644 src/eda_agent/tools/parts.py create mode 100644 tests/test_altium_local_provider.py create mode 100644 tests/test_distributor_providers.py create mode 100644 tests/test_part_providers.py create mode 100644 tests/test_public_libraries_provider.py diff --git a/src/eda_agent/libimport/providers/__init__.py b/src/eda_agent/libimport/providers/__init__.py new file mode 100644 index 0000000..41a8318 --- /dev/null +++ b/src/eda_agent/libimport/providers/__init__.py @@ -0,0 +1,241 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Part providers, queried equally, with no default. + +:func:`search_all` fans out to every enabled provider and returns their +hits attributed to their source. There is no preferred provider, no +fallback order, and no relevance ranking across sources: merged results +are ordered alphabetically by provider then by part, which carries no +quality judgement. + +That neutrality is enforced by the code, not left to convention. There +is no "default provider" setting to point somewhere, and a caller that +wants one source names it explicitly. A preferred provider would quietly +become the answer to every query and hand its operator the whole tool +surface, which is exactly what a multi-provider layer exists to avoid. + +A provider that cannot answer reports WHY. Fan-out never converts an +unavailable source into an empty result, because "the endpoint is gone" +and "no such part exists" are different answers and only one of them is +a reason to stop looking. + +Enable a subset with ``EDA_AGENT_PART_PROVIDERS`` (comma-separated). +Unset means all of them; the variable selects, it never ranks. +""" + +from __future__ import annotations + +import os +from typing import Any + +from eda_agent.libimport.providers.base import ( + ALTIUM_CONVERTIBLE_FORMATS, + PartHit, + PartProvider, + ProviderError, + ProviderUnavailable, +) +from eda_agent.libimport.providers.altium_local import ( + AltiumLocalProvider, +) +from eda_agent.libimport.providers.distributors import ( + DigiKeyProvider, + Element14Provider, + MouserProvider, + NexarProvider, + TmeProvider, +) +from eda_agent.libimport.providers.easyeda import EasyEdaProvider +from eda_agent.libimport.providers.kicad_local import KicadLocalProvider +from eda_agent.libimport.providers.partreel import PartReelProvider +from eda_agent.libimport.providers.public_libraries import ( + PublicLibrariesProvider, +) + +__all__ = [ + "PartHit", + "PartProvider", + "ProviderError", + "ProviderUnavailable", + "available_providers", + "get_provider", + "search_all", +] + +#: Registered in alphabetical order to make the absence of precedence +#: visible in the source. Adding one here must never imply a ranking. +#: +#: Two kinds sit side by side deliberately. The LIBRARY providers yield +#: geometry you can place; the CATALOGUE providers yield identity and a +#: datasheet and no geometry at all. They are not tiers and neither is a +#: fallback for the other: they answer different questions, and a search +#: reports which kind each hit came from rather than ordering one above +#: the other. +_ALL: tuple = ( + AltiumLocalProvider, + DigiKeyProvider, + EasyEdaProvider, + Element14Provider, + KicadLocalProvider, + MouserProvider, + NexarProvider, + PartReelProvider, + PublicLibrariesProvider, + TmeProvider, +) + + +def available_providers() -> list[PartProvider]: + """Every enabled provider, alphabetically by name.""" + selected = os.environ.get("EDA_AGENT_PART_PROVIDERS", "").strip() + wanted = {p.strip().lower() for p in selected.split(",") if p.strip()} + out = [cls() for cls in _ALL] + if wanted: + out = [p for p in out if p.name in wanted] + return sorted(out, key=lambda p: p.name) + + +def get_provider(name: str) -> PartProvider: + """One provider by name, for a caller that has already chosen.""" + key = str(name or "").strip().lower() + for provider in available_providers(): + if provider.name == key: + return provider + known = ", ".join(p.name for p in available_providers()) or "none" + raise ProviderError(f"unknown provider {name!r}; enabled: {known}") + + +def search_all(query: str, limit_per_provider: int = 20) -> dict[str, Any]: + """Query EVERY enabled provider and merge the hits. + + Returns ``{"hits": [...], "providers": {name: status}, "count": n}``. + + One provider failing never suppresses the others, and its failure is + reported per provider rather than folded into the result list, so a + thin set of hits can be told apart from a source that was down. + """ + hits: list[PartHit] = [] + status: dict[str, Any] = {} + by_provider: dict[str, Any] = {} + + for provider in available_providers(): + try: + found = provider.search(query, limit_per_provider) + except ProviderUnavailable as exc: + status[provider.name] = {"ok": False, "unavailable": str(exc)} + continue + except ProviderError as exc: + status[provider.name] = {"ok": False, "error": str(exc)} + continue + except Exception as exc: # noqa: BLE001 - one bad provider only + status[provider.name] = { + "ok": False, "error": f"{type(exc).__name__}: {exc}"} + continue + hits.extend(found) + by_provider[provider.name] = provider + status[provider.name] = { + "ok": True, + "count": len(found), + "kind": getattr(provider, "kind", "library"), + "formats": list(getattr(provider, "formats", ())), + "usable_in": list(getattr(provider, "usable_in", ())), + # Whether this project has ever exercised the client against + # the live API with a real credential. Published rather than + # assumed: the endpoints were measured, the response shapes + # were not, and conflating those two would be the same + # derived-instead-of-measured claim this project already + # rejected once for tool maturity. + "verified_live": bool(getattr(provider, "verified_live", True)), + } + + # Neutral ordering. NOT relevance: sorting by anything else would + # make one source systematically appear first. + hits.sort(key=lambda h: h.sort_key()) + return { + "count": len(hits), + "providers": status, + "hits": [_describe(h, by_provider.get(h.provider)) for h in hits], + "by_mpn": _correlate(hits), + } + + +def _describe(hit: PartHit, provider: Any) -> dict[str, Any]: + """A hit plus the next step that turns it into a real part. + + A hit on its own says nothing about whether it can be used: the + formats live on the provider, so a caller reading only the result + could not tell that a KiCad-format part is usable on Altium. The + answer is derived from ALTIUM_CONVERTIBLE_FORMATS, the same constant + that gates a provider's ``usable_in`` claim, so a format with no + converter can never be advertised as importable. + """ + out = hit.to_dict() + formats = list(getattr(provider, "formats", ())) + out["formats"] = formats + out["usable_in"] = list(getattr(provider, "usable_in", ())) + # "library" = geometry you can place. "catalogue" = identity and a + # datasheet, nothing to import. Without this the two are told apart + # only by an EMPTY import_with list, and absence is far too quiet a + # signal for a difference this consequential: a caller would find out + # that a distributor hit has no symbol only after choosing the part. + out["kind"] = getattr(provider, "kind", "library") + tools = [] + for fmt in formats: + tool = ALTIUM_CONVERTIBLE_FORMATS.get(fmt) + if tool and tool not in tools: + tools.append(tool) + # Named per format rather than as one blanket tool: a provider + # publishing several formats may need a different importer for each. + out["import_with"] = tools + return out + + +def _normalise_mpn(mpn: str) -> str: + """Fold ONLY the cosmetic differences in how sources spell an MPN. + + Case, spaces and punctuation carry no meaning, so they are dropped + before comparing. + + What this deliberately does NOT do is fold wildcards. KiCad writes + ``STM32F103C8Tx`` where a registry writes ``STM32F103C8T6``, and the + ``x`` is a family placeholder covering several variants with + different packages and temperature grades. Treating them as one part + would assert an equivalence this code cannot support, and the whole + point of surfacing provenance is to avoid that kind of claim. They + stay separate groups, and a human decides. + """ + return "".join(c for c in str(mpn).lower() if c.isalnum()) + + +def _correlate(hits: list[PartHit]) -> list[dict[str, Any]]: + """Which providers carry each part, with no source preferred. + + Answers the question a multi-provider search actually raises: "who + has this part, and what does each of them know about it?" Providers + are listed alphabetically inside every group for the same reason the + hit list is: any other order would read as a recommendation. + """ + groups: dict[str, dict[str, Any]] = {} + for hit in hits: + key = _normalise_mpn(hit.mpn) or hit.part_id.lower() + entry = groups.setdefault(key, {"mpn": hit.mpn, "providers": []}) + entry["providers"].append({ + "provider": hit.provider, + "part_id": hit.part_id, + # Surfaced per source, because they differ: a registry may + # state provenance and a license where a symbol library + # states neither, and that difference is the useful signal. + # NOTE these come from the SEARCH hit. A provider whose + # index is thinner than its detail endpoint (PartReel states + # a license on fetch but not in the index) shows blank here, + # and blank means "not stated in the index", not "none". + "provenance": hit.provenance, + "license": hit.license, + "datasheet": hit.datasheet, + }) + for entry in groups.values(): + entry["providers"].sort(key=lambda p: p["provider"]) + entry["provider_count"] = len(entry["providers"]) + # Group order follows the same neutral rule as the hit list. + return sorted(groups.values(), + key=lambda e: (e["mpn"].lower(), -e["provider_count"])) diff --git a/src/eda_agent/libimport/providers/_distributor.py b/src/eda_agent/libimport/providers/_distributor.py new file mode 100644 index 0000000..97e88fb --- /dev/null +++ b/src/eda_agent/libimport/providers/_distributor.py @@ -0,0 +1,259 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Shared machinery for the distributor and aggregator catalogues. + +These sources answer a different question from the library providers. +EasyEDA and the local libraries hand back GEOMETRY: a symbol, a +footprint, something to place. A distributor hands back IDENTITY: the +manufacturer part number, the datasheet URL, the lifecycle status, what +is actually in stock. You cannot place a Digi-Key hit on a schematic, +and pretending otherwise would be the more damaging kind of lie because +the caller only finds out after choosing the part. + +So they declare ``kind = "catalogue"`` and no importable format at all. +What they are FOR is the step before the symbol: deciding which part to +use, and getting the datasheet that every other check in this project +measures against. + +Every endpoint constant in this module was probed live before it was +written down. That matters more than it sounds: of eight candidate APIs +checked, three (SnapEDA, LCSC, Arrow's keyword path) answered 404 on the +URL recalled for them and were dropped rather than shipped broken. A +401 or 403 is the useful answer here, because it proves the host and +path exist and refused us only for lack of a credential, which is +exactly what an unconfigured provider should report. + +CREDENTIALS: every provider here needs one, none ships with a default, +and an unconfigured provider raises :class:`ProviderUnavailable` naming +the exact environment variables it wants. That is deliberately the same +treatment the parts registry gets. A source that silently returned +nothing when unconfigured would read as "this part does not exist". +""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.parse +import urllib.request +from typing import Any, Sequence + +from eda_agent.libimport.providers.base import ( + PartHit, + ProviderError, + ProviderUnavailable, +) + +__all__ = ["DistributorProvider"] + +#: Network timeout. A part search is interactive, so a source that has +#: not answered by now should be reported as slow rather than block the +#: other providers in the fan-out. +_TIMEOUT = 20 + +#: Substrings that mark an error payload returned under HTTP 200. +#: Mouser does exactly this: an invalid API key comes back as 200 with +#: ``{"Errors":[{"Code":"Invalid",...}]}``, measured live. A client that +#: checked only the status code would report a rejected key as a +#: successful search that happened to match nothing, which is the single +#: worst failure mode this whole layer exists to prevent. +_ERROR_KEYS = ("Errors", "errors", "error", "ErrorMessage") + + +class DistributorProvider: + """A credential-gated catalogue of part identity and datasheets. + + Subclasses supply the endpoint, the auth, and the response mapping. + This base owns the parts that must not vary: refusing to run + unconfigured, refusing to treat an error payload as an empty result, + and never putting a credential where it could be logged. + """ + + #: What a hit from this source IS, as opposed to what it is about. + #: "catalogue" = identity, datasheet, availability, NO geometry. + #: "library" = symbol or footprint files you can actually import. + kind = "catalogue" + + #: Nothing is downloadable, so nothing is convertible. This is a + #: statement about the source, not an omission to be filled in later. + formats: tuple = () + native_to: tuple = () + + #: The identity and the datasheet are tool-neutral: an MPN is as + #: useful to a KiCad user as to an Altium one. Nothing is imported + #: from here, which ``kind`` states and ``formats`` confirms. + usable_in = ("altium", "kicad") + + #: Whether this client has ever been exercised against the live API + #: with a real credential. False means the ENDPOINT was verified to + #: exist but the request and response shapes have not been confirmed + #: by this project. Published rather than assumed, for the same + #: reason tool maturity is measured rather than derived: a claim + #: nobody checked is worth less than an honest blank. + verified_live = False + + #: Environment variables this provider needs, all of them required. + env_vars: tuple = () + + name = "" + description = "" + + # ---- credentials ------------------------------------------------- + + def _credentials(self) -> dict[str, str]: + """The configured credentials, or refuse and say what is missing. + + Names the variables rather than saying "not configured", because + the caller cannot act on the latter. + """ + found = {} + missing = [] + for var in self.env_vars: + value = os.environ.get(var, "").strip() + if value: + found[var] = value + else: + missing.append(var) + if missing: + raise ProviderUnavailable( + f"{self.name} needs {' and '.join(missing)}; set " + f"{'them' if len(missing) > 1 else 'it'} to enable this " + f"source. No credential ships with this project.") + return found + + # ---- transport --------------------------------------------------- + + def _request( + self, + url: str, + *, + method: str = "GET", + body: bytes | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + """One HTTP call, returning parsed JSON. + + Failures are classified rather than merged: a credential problem + is :class:`ProviderUnavailable` (configure something), anything + else is :class:`ProviderError` (the query failed). The fan-out + reports those differently and only one of them means the part + might still exist elsewhere. + """ + request = urllib.request.Request( + url, data=body, method=method, + headers={"User-Agent": "eda-agent", **(headers or {})}) + try: + with urllib.request.urlopen(request, timeout=_TIMEOUT) as response: + raw = response.read() + except urllib.error.HTTPError as exc: + if exc.code in (401, 403): + raise ProviderUnavailable( + f"{self.name} rejected the credential in " + f"{' / '.join(self.env_vars)} (HTTP {exc.code}). The " + f"endpoint is reachable, so this is a key problem, " + f"not an outage.") from exc + if exc.code == 429: + raise ProviderUnavailable( + f"{self.name} rate-limited this client (HTTP 429); " + f"the part may still exist.") from exc + raise ProviderError( + f"{self.name} returned HTTP {exc.code}") from exc + except (urllib.error.URLError, TimeoutError, OSError) as exc: + raise ProviderUnavailable( + f"{self.name} unreachable: {exc}. This is not evidence " + f"the part does not exist.") from exc + + try: + payload = json.loads(raw.decode("utf-8", "replace")) + except ValueError as exc: + raise ProviderError( + f"{self.name} returned a body that is not JSON") from exc + + self._reject_error_payload(payload) + return payload + + def _reject_error_payload(self, payload: Any) -> None: + """Refuse an error delivered under a success status code. + + Measured against the live Mouser API, which answers an invalid + key with HTTP 200 and an ``Errors`` array. Without this the + search would look like it ran and matched nothing. + """ + if not isinstance(payload, dict): + return + for key in _ERROR_KEYS: + problem = payload.get(key) + # An empty list or empty string is the SUCCESS case for these + # APIs: they include the key unconditionally. Only a + # populated value is an actual error. + if not problem: + continue + detail = json.dumps(problem)[:200] + raise ProviderUnavailable( + f"{self.name} returned an error under HTTP 200: {detail}. " + f"This is usually a rejected or missing credential; it is " + f"NOT an empty result.") + + # ---- helpers for subclasses -------------------------------------- + + @staticmethod + def _query(url: str, params: dict[str, str]) -> str: + return f"{url}?{urllib.parse.urlencode(params)}" + + def _hit(self, part_id: str, **fields: Any) -> PartHit: + """A hit attributed to this source, with provenance stated. + + Provenance is filled in here rather than left to each subclass so + that no catalogue hit can arrive claiming to be a verified part. + """ + fields.setdefault( + "provenance", + f"{self.name} catalogue entry; identity and datasheet only, " + f"no symbol or footprint") + return PartHit(provider=self.name, part_id=str(part_id), **fields) + + # ---- contract ---------------------------------------------------- + + def search(self, query: str, limit: int = 20) -> list[PartHit]: + raise NotImplementedError + + def fetch(self, part_id: str) -> dict[str, Any]: + """Detail for one part. + + The default re-runs the search and matches exactly, which every + catalogue here supports without a second endpoint. + """ + for hit in self.search(part_id, limit=50): + if hit.part_id == part_id or hit.mpn == part_id: + detail = hit.to_dict() + detail["kind"] = self.kind + detail["files"] = {} + detail["note"] = ( + f"{self.name} supplies part identity and a datasheet, " + f"NOT a symbol or footprint. Build the part from the " + f"datasheet, or find geometry through a library " + f"provider.") + return detail + raise ProviderError(f"{self.name} has no part {part_id!r}") + + +def first_string(source: Any, *paths: Sequence[str]) -> str: + """First non-empty string reachable by any of ``paths``. + + Distributor payloads nest inconsistently and rename fields between + API versions. Walking several candidate paths and taking the first + hit keeps one renamed field from emptying a whole result, instead of + raising on a shape that is merely different from the documented one. + """ + for path in paths: + node: Any = source + for step in path: + if isinstance(node, dict): + node = node.get(step) + else: + node = None + break + if isinstance(node, (str, int, float)) and str(node).strip(): + return str(node).strip() + return "" diff --git a/src/eda_agent/libimport/providers/_http.py b/src/eda_agent/libimport/providers/_http.py new file mode 100644 index 0000000..aa80d6f --- /dev/null +++ b/src/eda_agent/libimport/providers/_http.py @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Hardened HTTP fetch shared by the part providers. + +Each provider talks to a third party over the network, so the same +protections apply to all of them and live in one place rather than being +re-derived per provider (where one would eventually be forgotten): + +* HTTPS only, so a downgraded URL cannot leak or be tampered with +* an explicit host allowlist per call, so a redirect or a mis-set + environment override cannot send requests somewhere unexpected +* a byte cap, so a hostile or broken endpoint cannot exhaust memory +* a timeout, so a hung server cannot stall the MCP server + +The cache is deliberately simple and on disk: a provider without +server-side search has to pull a whole index to answer one query, and +re-downloading megabytes per keystroke is not acceptable. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +__all__ = ["FetchError", "cache_dir", "get_bytes", "get_json_cached"] + +#: Generous enough for a whole parts index, small enough to bound memory. +MAX_BYTES = 50 * 1024 * 1024 +TIMEOUT_S = 30.0 + +_UA = "eda-agent (+https://github.com/salitronic/eda-agent)" + + +class FetchError(RuntimeError): + """Network or protocol failure, carrying the URL for diagnosis.""" + + +def cache_dir() -> Path: + """Where provider indexes are cached. + + Override with ``EDA_AGENT_CACHE_DIR``. Defaults under the user's + local app data rather than the repo, so a checkout stays clean and a + cache never lands in version control. + """ + override = os.environ.get("EDA_AGENT_CACHE_DIR") + if override: + return Path(override) + base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~/.cache") + return Path(base) / "eda-agent" / "parts-cache" + + +def _check(url: str, allowed_hosts: set[str]) -> None: + parts = urllib.parse.urlparse(url) + if parts.scheme != "https": + raise FetchError(f"refusing non-HTTPS url: {url}") + host = (parts.hostname or "").lower() + ok = any(host == h or host.endswith("." + h) for h in allowed_hosts) + if not ok: + raise FetchError( + f"host {host!r} is not in this provider's allowlist " + f"({sorted(allowed_hosts)}); refusing to fetch {url}") + + +def get_bytes(url: str, allowed_hosts: set[str]) -> bytes: + """Fetch a URL with every protection applied.""" + _check(url, allowed_hosts) + request = urllib.request.Request( + url, headers={"User-Agent": _UA, "Accept": "*/*"}) + try: + with urllib.request.urlopen(request, timeout=TIMEOUT_S) as response: + # Read one byte past the cap so truncation is detectable + # rather than silently returning a partial document. + data = response.read(MAX_BYTES + 1) + except urllib.error.HTTPError as exc: + raise FetchError(f"HTTP {exc.code} from {url}") from exc + except urllib.error.URLError as exc: + raise FetchError(f"cannot reach {url}: {exc.reason}") from exc + except OSError as exc: + raise FetchError(f"cannot reach {url}: {exc}") from exc + if len(data) > MAX_BYTES: + raise FetchError( + f"response from {url} exceeds {MAX_BYTES} bytes; refusing to " + f"buffer it") + return data + + +def get_json_cached(url: str, allowed_hosts: set[str], + ttl_s: float = 86400.0) -> Any: + """Fetch JSON, reusing a cached copy within ``ttl_s``. + + A stale-but-readable cache is preferred to a hard failure when the + network is down: a parts index that is a day old still answers most + questions, whereas an exception answers none. The staleness is + bounded by the TTL on the happy path. + """ + key = hashlib.sha256(url.encode("utf-8")).hexdigest()[:32] + path = cache_dir() / f"{key}.json" + + if path.exists(): + try: + age = time.time() - path.stat().st_mtime + if age < ttl_s: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + pass # unreadable cache is simply a miss + + try: + raw = get_bytes(url, allowed_hosts) + except FetchError: + # Fall back to whatever is cached, however old, before giving up. + if path.exists(): + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + pass + raise + + text = raw.decode("utf-8", "replace") + try: + data = json.loads(text) + except ValueError as exc: + raise FetchError(f"{url} did not return JSON: {exc}") from exc + + try: + path.parent.mkdir(parents=True, exist_ok=True) + # Write via a temp file so a crash mid-write cannot leave a + # truncated cache that later parses as valid-but-wrong. + tmp = path.with_suffix(".tmp") + tmp.write_text(text, encoding="utf-8") + tmp.replace(path) + except OSError: + pass # caching is an optimisation, never a requirement + + return data diff --git a/src/eda_agent/libimport/providers/altium_local.py b/src/eda_agent/libimport/providers/altium_local.py new file mode 100644 index 0000000..a1ac489 --- /dev/null +++ b/src/eda_agent/libimport/providers/altium_local.py @@ -0,0 +1,162 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""The Altium libraries already on this machine. + +The one source that answers a question none of the others can: do I +ALREADY have this part? Every other provider proposes something to +import, and importing a part you own produces a duplicate symbol with a +slightly different name, which is how a library rots. + +No network, no login, no rate limit, and no third party. It reads the +``.SchLib`` files on disk directly through ``fileio.altium_schlib``, so +it works with Altium closed and the polling loop down. + +There is nothing to download. A hit here is already in Altium's own +format, so ``fetch`` returns the library path and the component name +rather than files: the caller places it with the existing library +tools instead of converting anything. + +Roots come from ``EDA_AGENT_ALTIUM_LIBRARIES`` (a path-separator list). +Without it the usual install locations are searched. Nothing is +searched recursively beyond those roots, because a scan of an entire +drive is not a thing a search tool should do behind the user's back. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, Iterable, Optional + +from eda_agent.libimport.providers.base import ( + PartHit, + ProviderError, + ProviderUnavailable, +) + +__all__ = ["AltiumLocalProvider"] + +#: Where Altium keeps libraries by default. Checked in order; every one +#: that exists contributes, because a machine often has both a shared +#: and a per-user library. +_DEFAULT_ROOTS = ( + r"%PUBLIC%\Documents\Altium\Library", + r"%USERPROFILE%\Documents\Altium\Library", + r"%USERPROFILE%\Documents\Altium\Projects", +) + + +def _roots() -> list[Path]: + raw = os.environ.get("EDA_AGENT_ALTIUM_LIBRARIES", "").strip() + if raw: + return [Path(os.path.expandvars(p)).expanduser() + for p in raw.split(os.pathsep) if p.strip()] + return [Path(os.path.expandvars(r)).expanduser() for r in _DEFAULT_ROOTS] + + +def _schlibs() -> list[Path]: + """Every readable .SchLib under the configured roots.""" + found: list[Path] = [] + seen: set[str] = set() + for root in _roots(): + if not root.is_dir(): + continue + for path in sorted(root.rglob("*.SchLib")): + key = str(path).lower() + if key not in seen: + seen.add(key) + found.append(path) + return found + + +class AltiumLocalProvider: + """Search the Altium schematic libraries installed on this machine.""" + + name = "altium_local" + description = ( + "The .SchLib libraries already on this machine. Answers whether " + "you ALREADY own a part, which no other source can. No network " + "and no login; reads the OLE files directly, so it works with " + "Altium closed. Nothing to download: a hit is already an Altium " + "symbol. Set EDA_AGENT_ALTIUM_LIBRARIES to point it elsewhere.") + + #: Nothing is fetched, so no convertible format is offered. This is + #: not an omission: the part is already in the target format. + formats: tuple = () + usable_in = ("altium",) + + #: Backends this source is ALREADY native to, so a claim of + #: usability needs no converter. Declared positively rather than + #: inferred from an empty ``formats``, otherwise omitting formats + #: would be a way to dodge the converter check rather than a + #: statement about the parts. + native_to = ("altium",) + + def _components(self) -> Iterable[tuple[Path, dict[str, Any]]]: + from eda_agent.fileio.altium_schlib import read_schlib_components + + libs = _schlibs() + if not libs: + raise ProviderUnavailable( + "no Altium .SchLib libraries found. Looked in " + + ", ".join(str(r) for r in _roots()) + + ". Set EDA_AGENT_ALTIUM_LIBRARIES to a path-separated " + "list of library folders.") + for lib in libs: + try: + for comp in read_schlib_components(lib): + yield lib, comp + except (ValueError, OSError): + # One unreadable library must not hide the rest. A + # corrupt or in-use file is common on a shared drive. + continue + + def search(self, query: str, limit: int = 20) -> list[PartHit]: + needle = (query or "").strip().lower() + hits: list[PartHit] = [] + for lib, comp in self._components(): + name = str(comp.get("name") or comp.get("lib_reference") or "") + if not name: + continue + description = str(comp.get("description") or "") + if needle and needle not in name.lower() \ + and needle not in description.lower(): + continue + hits.append(PartHit( + provider=self.name, + # Qualified by library so two libraries may hold the + # same symbol name without colliding. + part_id=f"{lib.name}::{name}", + mpn="", + manufacturer="", + description=description, + provenance=f"already installed: {lib}", + extra={"library_path": str(lib), "component": name}, + )) + if len(hits) >= limit: + break + return hits + + def fetch(self, part_id: str) -> dict[str, Any]: + lib_name, _, comp_name = (part_id or "").partition("::") + if not lib_name or not comp_name: + raise ProviderError( + f"part_id must be '::', got " + f"{part_id!r}") + for lib, comp in self._components(): + name = str(comp.get("name") or comp.get("lib_reference") or "") + if lib.name.lower() == lib_name.lower() and name == comp_name: + return { + "provider": self.name, + "part_id": part_id, + "component": name, + "library_path": str(lib), + "description": str(comp.get("description") or ""), + "lib_reference": str(comp.get("lib_reference") or name), + "files": {}, + "note": ( + "already an Altium symbol, nothing downloaded. " + "Place it with lib_ tools against this library " + "rather than importing a second copy."), + } + raise ProviderError(f"no component {comp_name!r} in {lib_name!r}") diff --git a/src/eda_agent/libimport/providers/base.py b/src/eda_agent/libimport/providers/base.py new file mode 100644 index 0000000..950d91c --- /dev/null +++ b/src/eda_agent/libimport/providers/base.py @@ -0,0 +1,145 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Provider-neutral contract for part sources. + +Every source of parts (a public registry, a vendor API, a local library) +implements :class:`PartProvider`. Nothing here ranks providers, and +nothing designates a default: :func:`search_all` queries all of them and +returns their hits attributed to their source, so the caller decides. + +That is a deliberate structural choice, not a convention to remember. A +"preferred" provider would quietly become the answer to every query, and +the operator of that provider would inherit the whole tool surface. Any +ordering applied to merged results is alphabetical by provider then by +part, which carries no quality judgement. + +A provider that is unavailable (endpoint withdrawn, needs credentials, +software not installed) must say so through +:class:`ProviderUnavailable` rather than returning nothing. Silence +reads as "no parts matched", which is a different and misleading answer. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Protocol, runtime_checkable + +__all__ = [ + "PartHit", + "PartProvider", + "ProviderError", + "ProviderUnavailable", +] + + +#: Formats this server can actually convert into an Altium library, and +#: the tool that does it. A provider may only claim ``usable_in`` +#: "altium" if one of its formats appears here: the claim has to be tied +#: to a converter that exists, not to an intention. +ALTIUM_CONVERTIBLE_FORMATS = { + "easyeda_json": "lib_easyeda_import", + "kicad_sym": "lib_kicad_import", + "kicad_mod": "lib_kicad_import", +} + + +class ProviderError(RuntimeError): + """A provider failed in a way the caller may want to see.""" + + +class ProviderUnavailable(ProviderError): + """The provider cannot answer at all right now. + + Distinct from "no results": a withdrawn endpoint, a missing + credential or an uninstalled tool is not evidence that the part does + not exist, and must never be reported as an empty result set. + """ + + +@dataclass +class PartHit: + """One candidate part, attributed to the provider that found it.""" + + provider: str + part_id: str + mpn: str = "" + manufacturer: str = "" + package: str = "" + description: str = "" + datasheet: str = "" + #: What the provider says about where its geometry came from. Free + #: text; ``design_validate``'s atomic-parts checks want provenance, + #: and a source that cannot supply any is itself a signal. + provenance: str = "" + #: License of the downloadable artefacts, when the provider states + #: one. Blank means unknown, which is not the same as permissive. + license: str = "" + #: Anything provider-specific a later fetch needs. + extra: dict[str, Any] = field(default_factory=dict) + + def sort_key(self) -> tuple[str, str, str]: + """Neutral ordering: provider, then MPN, then id. + + Explicitly NOT a relevance or quality ranking. Merged results + must not imply one source is better than another. + """ + return (self.provider.lower(), (self.mpn or "").lower(), + self.part_id.lower()) + + @property + def ref(self) -> str: + """One handle that carries its own source: ``provider:part_id``. + + Lets ``part_fetch`` take a single argument instead of making the + caller pair an id with the provider it came from. The source is + still explicit, it just travels WITH the id rather than beside + it, so merged results behave like one catalogue without any + source becoming an implied default. + + Split on the FIRST colon only: provider names are identifiers + and contain none, while part ids routinely do (``Device:R``, + ``Lib.SchLib::Comp``). + """ + return f"{self.provider}:{self.part_id}" + + def to_dict(self) -> dict[str, Any]: + return { + "ref": self.ref, + "provider": self.provider, + "part_id": self.part_id, + "mpn": self.mpn, + "manufacturer": self.manufacturer, + "package": self.package, + "description": self.description, + "datasheet": self.datasheet, + "provenance": self.provenance, + "license": self.license, + } + + +@runtime_checkable +class PartProvider(Protocol): + """What a part source must implement to take part in a search.""" + + #: Stable lowercase identifier, used to address the provider. + name: str + + #: One line for the catalog, including any disclosure the operator + #: should see (who runs it, what it costs, whether it needs a login). + description: str + + def search(self, query: str, limit: int = 20) -> list[PartHit]: + """Candidates matching ``query``. + + Raise :class:`ProviderUnavailable` when the source cannot be + reached or has no search facility. Return an empty list ONLY + when the search genuinely ran and matched nothing. + """ + ... + + def fetch(self, part_id: str) -> dict[str, Any]: + """Everything needed to build the part, provider-specific shape. + + Raise :class:`ProviderError` on failure. + """ + ... diff --git a/src/eda_agent/libimport/providers/distributors.py b/src/eda_agent/libimport/providers/distributors.py new file mode 100644 index 0000000..d3114bb --- /dev/null +++ b/src/eda_agent/libimport/providers/distributors.py @@ -0,0 +1,392 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Distributor and aggregator catalogues. + +Five sources of part IDENTITY: manufacturer part number, datasheet, +lifecycle and stock. None of them yields a symbol or a footprint, which +is why they all declare ``kind = "catalogue"`` and no importable format. + +They matter because of what this project measures everything against. +``lib_audit_footprint_vs_datasheet`` and the design checks all want a +datasheet URL, and until now nothing here could find one: the library +providers know a symbol's name but not the part's paperwork. + +Every endpoint below was probed live before being written down. The +comment on each class records what it answered, because "I checked" is +worth nothing without the result. Three further candidates were probed +and DROPPED for answering 404 on the recalled URL rather than being +shipped as plausible guesses. + +WHAT IS AND IS NOT VERIFIED HERE. The endpoints exist: measured. The +auth mechanisms follow each vendor's published scheme. The response +parsing has NOT been exercised against a live credentialed reply by this +project, which every class states through ``verified_live = False`` and +which the provider catalogue reports. The parsers are written to survive +a renamed field rather than assume one, so a shape drift degrades a hit +instead of losing the whole search. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import urllib.parse +from typing import Any + +from eda_agent.libimport.providers._distributor import ( + DistributorProvider, + first_string, +) +from eda_agent.libimport.providers.base import PartHit + +__all__ = [ + "DigiKeyProvider", + "Element14Provider", + "MouserProvider", + "NexarProvider", + "TmeProvider", +] + + +class DigiKeyProvider(DistributorProvider): + """Digi-Key's product search. + + Probed: ``POST /products/v4/search/keyword`` answered HTTP 400 to an + unauthenticated request and ``POST /v1/oauth2/token`` answered 400 to + an empty grant, so both paths exist and reject rather than 404. + """ + + name = "digikey" + description = ( + "Digi-Key catalogue: MPN, datasheet, lifecycle and live stock. " + "Identity only, no symbol or footprint. Needs an OAuth client " + "from the Digi-Key developer portal via DIGIKEY_CLIENT_ID and " + "DIGIKEY_CLIENT_SECRET.") + env_vars = ("DIGIKEY_CLIENT_ID", "DIGIKEY_CLIENT_SECRET") + + _TOKEN_URL = "https://api.digikey.com/v1/oauth2/token" + _SEARCH_URL = "https://api.digikey.com/products/v4/search/keyword" + + def _token(self) -> str: + creds = self._credentials() + body = urllib.parse.urlencode({ + "client_id": creds["DIGIKEY_CLIENT_ID"], + "client_secret": creds["DIGIKEY_CLIENT_SECRET"], + "grant_type": "client_credentials", + }).encode("ascii") + payload = self._request( + self._TOKEN_URL, method="POST", body=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}) + token = first_string(payload, ("access_token",)) + if not token: + raise self._no_token() + return token + + def _no_token(self): + from eda_agent.libimport.providers.base import ProviderUnavailable + return ProviderUnavailable( + f"{self.name} accepted the credential exchange but returned no " + f"access_token") + + def search(self, query: str, limit: int = 20) -> list[PartHit]: + token = self._token() + creds = self._credentials() + body = json.dumps({ + "Keywords": str(query), + "Limit": int(limit), + "Offset": 0, + }).encode("utf-8") + payload = self._request( + self._SEARCH_URL, method="POST", body=body, + headers={ + "Authorization": f"Bearer {token}", + "X-DIGIKEY-Client-Id": creds["DIGIKEY_CLIENT_ID"], + "Content-Type": "application/json", + }) + products = payload.get("Products") or payload.get("products") or [] + hits = [] + for product in products[:limit]: + mpn = first_string(product, + ("ManufacturerProductNumber",), + ("ManufacturerPartNumber",), + ("Description", "ProductDescription")) + if not mpn: + continue + hits.append(self._hit( + mpn, + mpn=mpn, + manufacturer=first_string(product, + ("Manufacturer", "Name"), + ("Manufacturer", "Value")), + description=first_string( + product, ("Description", "ProductDescription"), + ("DetailedDescription",)), + datasheet=first_string(product, ("DatasheetUrl",), + ("PrimaryDatasheet",)), + )) + return hits + + +class MouserProvider(DistributorProvider): + """Mouser's keyword search. + + Probed: ``POST /api/v1/search/keyword`` answered HTTP **200** with + ``{"Errors":[{"Code":"Invalid",...}]}`` to a bogus key. That is the + reason ``_reject_error_payload`` exists in the base class: judged on + status alone, a rejected credential here is indistinguishable from a + search that ran and matched nothing. + """ + + name = "mouser" + description = ( + "Mouser catalogue: MPN, datasheet, lifecycle and stock. Identity " + "only, no symbol or footprint. Needs a search API key from the " + "Mouser developer portal via MOUSER_API_KEY.") + env_vars = ("MOUSER_API_KEY",) + + _SEARCH_URL = "https://api.mouser.com/api/v1/search/keyword" + + def search(self, query: str, limit: int = 20) -> list[PartHit]: + creds = self._credentials() + body = json.dumps({ + "SearchByKeywordRequest": { + "keyword": str(query), + "records": int(limit), + "startingRecord": 0, + } + }).encode("utf-8") + payload = self._request( + self._query(self._SEARCH_URL, + {"apiKey": creds["MOUSER_API_KEY"]}), + method="POST", body=body, + headers={"Content-Type": "application/json"}) + results = (payload.get("SearchResults") or {}) + parts = results.get("Parts") or [] + hits = [] + for part in parts[:limit]: + mpn = first_string(part, ("ManufacturerPartNumber",)) + if not mpn: + continue + hits.append(self._hit( + mpn, + mpn=mpn, + manufacturer=first_string(part, ("Manufacturer",)), + description=first_string(part, ("Description",)), + datasheet=first_string(part, ("DataSheetUrl",)), + )) + return hits + + +class NexarProvider(DistributorProvider): + """Nexar, the API behind Octopart. + + Probed: ``POST https://api.nexar.com/graphql`` answered a valid + GraphQL body to an unauthenticated ``{__typename}`` introspection, + and ``https://identity.nexar.com/connect/token`` answered 400 to an + empty grant. Both exist; real part queries need the token. + """ + + name = "nexar" + description = ( + "Nexar (Octopart) aggregator: MPN, datasheet and offers across " + "many distributors at once. Identity only, no symbol or " + "footprint. Needs NEXAR_CLIENT_ID and NEXAR_CLIENT_SECRET.") + env_vars = ("NEXAR_CLIENT_ID", "NEXAR_CLIENT_SECRET") + + _TOKEN_URL = "https://identity.nexar.com/connect/token" + _API_URL = "https://api.nexar.com/graphql" + + _QUERY = """ + query SearchMpn($q: String!, $limit: Int!) { + supSearchMpn(q: $q, limit: $limit) { + results { + part { + mpn + manufacturer { name } + shortDescription + bestDatasheet { url } + } + } + } + } + """ + + def _token(self) -> str: + creds = self._credentials() + body = urllib.parse.urlencode({ + "client_id": creds["NEXAR_CLIENT_ID"], + "client_secret": creds["NEXAR_CLIENT_SECRET"], + "grant_type": "client_credentials", + }).encode("ascii") + payload = self._request( + self._TOKEN_URL, method="POST", body=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}) + token = first_string(payload, ("access_token",)) + if not token: + from eda_agent.libimport.providers.base import ProviderUnavailable + raise ProviderUnavailable( + f"{self.name} returned no access_token") + return token + + def search(self, query: str, limit: int = 20) -> list[PartHit]: + token = self._token() + body = json.dumps({ + "query": self._QUERY, + "variables": {"q": str(query), "limit": int(limit)}, + }).encode("utf-8") + payload = self._request( + self._API_URL, method="POST", body=body, + headers={"Authorization": f"Bearer {token}", + "Content-Type": "application/json"}) + results = (((payload.get("data") or {}).get("supSearchMpn") or {}) + .get("results") or []) + hits = [] + for entry in results[:limit]: + part = (entry or {}).get("part") or {} + mpn = first_string(part, ("mpn",)) + if not mpn: + continue + hits.append(self._hit( + mpn, + mpn=mpn, + manufacturer=first_string(part, ("manufacturer", "name")), + description=first_string(part, ("shortDescription",)), + datasheet=first_string(part, ("bestDatasheet", "url")), + )) + return hits + + +class Element14Provider(DistributorProvider): + """element14 / Farnell / Newark product search. + + Probed: ``GET /catalog/products`` answered HTTP 403 with the body + ``Developer Inactive``, so the path exists and gates on the key. + """ + + name = "element14" + description = ( + "element14 (Farnell, Newark) catalogue: MPN, datasheet and " + "stock. Identity only, no symbol or footprint. Needs a product " + "search key via ELEMENT14_API_KEY; set ELEMENT14_STORE to pick " + "a regional store.") + env_vars = ("ELEMENT14_API_KEY",) + + _SEARCH_URL = "https://api.element14.com/catalog/products" + #: Regional storefront. element14 serves different catalogues per + #: store, so this changes which parts and prices come back. + _DEFAULT_STORE = "uk.farnell.com" + + def search(self, query: str, limit: int = 20) -> list[PartHit]: + import os + + creds = self._credentials() + store = os.environ.get("ELEMENT14_STORE", "").strip() \ + or self._DEFAULT_STORE + url = self._query(self._SEARCH_URL, { + "term": f"any:{query}", + "storeInfo.id": store, + "callInfo.apiKey": creds["ELEMENT14_API_KEY"], + "callInfo.responseDataFormat": "json", + "resultsSettings.numberOfResults": str(int(limit)), + "resultsSettings.offset": "0", + "resultsSettings.responseGroup": "large", + }) + payload = self._request(url) + products = ((payload.get("premierFarnellPartNumberReturn") or {}) + .get("products") or []) + hits = [] + for product in products[:limit]: + mpn = first_string(product, ("translatedManufacturerPartNumber",), + ("sku",)) + if not mpn: + continue + datasheets = product.get("datasheets") or [] + datasheet = "" + if isinstance(datasheets, list) and datasheets: + datasheet = first_string(datasheets[0], ("url",)) + hits.append(self._hit( + mpn, + mpn=mpn, + manufacturer=first_string(product, ("brandName",), + ("vendorName",)), + description=first_string( + product, ("displayName",), + ("productOverview", "description")), + datasheet=datasheet, + )) + return hits + + +class TmeProvider(DistributorProvider): + """TME (Transfer Multisort Elektronik) product search. + + Probed: ``POST /Products/Search.json`` answered HTTP 403 with + ``{"Status":"E_ACTION_FORBIDDEN"}``, so the path exists and gates on + the signed request. + + TME signs every call: HMAC-SHA1 over the method, the URL and the + sorted parameters, base64 encoded. That is implemented here but, like + every parser in this module, has not been exercised against a live + credentialed reply. + """ + + name = "tme" + description = ( + "TME catalogue: MPN, datasheet and stock, strongest on European " + "availability. Identity only, no symbol or footprint. Needs " + "TME_TOKEN and TME_SECRET; requests are HMAC signed.") + env_vars = ("TME_TOKEN", "TME_SECRET") + + _SEARCH_URL = "https://api.tme.eu/Products/Search.json" + + def _sign(self, url: str, params: dict[str, str], secret: str) -> str: + """TME's HMAC-SHA1 request signature. + + The signed string is METHOD&url¶ms, each percent-encoded as a + whole, with parameters sorted by key. + """ + encoded = urllib.parse.urlencode(sorted(params.items())) + base = "&".join([ + "POST", + urllib.parse.quote(url, safe=""), + urllib.parse.quote(encoded, safe=""), + ]) + digest = hmac.new(secret.encode("utf-8"), base.encode("utf-8"), + hashlib.sha1).digest() + return base64.b64encode(digest).decode("ascii") + + def search(self, query: str, limit: int = 20) -> list[PartHit]: + import os + + creds = self._credentials() + params = { + "Token": creds["TME_TOKEN"], + "SearchPlain": str(query), + "Country": os.environ.get("TME_COUNTRY", "").strip() or "GB", + "Language": "EN", + } + params["ApiSignature"] = self._sign( + self._SEARCH_URL, params, creds["TME_SECRET"]) + body = urllib.parse.urlencode(params).encode("utf-8") + payload = self._request( + self._SEARCH_URL, method="POST", body=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}) + products = ((payload.get("Data") or {}).get("ProductList") or []) + hits = [] + for product in products[:limit]: + mpn = first_string(product, ("OriginalSymbol",), ("Symbol",)) + if not mpn: + continue + hits.append(self._hit( + mpn, + mpn=mpn, + manufacturer=first_string(product, ("Producer",)), + description=first_string(product, ("Description",)), + # TME returns documents from a separate endpoint; the + # search reply carries a product page rather than a + # datasheet. Stated as a page, not passed off as one. + datasheet=first_string(product, ("ProductInformationPage",)), + )) + return hits diff --git a/src/eda_agent/libimport/providers/easyeda.py b/src/eda_agent/libimport/providers/easyeda.py new file mode 100644 index 0000000..b14e8f6 --- /dev/null +++ b/src/eda_agent/libimport/providers/easyeda.py @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""EasyEDA / LCSC as a part provider. + +Asymmetric on purpose, because the service is: + +* FETCH by LCSC id works, unauthenticated, and is what the converter + already uses. +* SEARCH does not exist any more. Verified against the live service: + the EasyEDA search route answers 404/403 with an HTML error page and + no auth challenge, LCSC's own global-search returns HTTP 200 carrying + ``{"code": 404, "ok": false}``, and the LCSC results page is rendered + client-side so fetching it yields zero part numbers. + +So :meth:`search` raises ``ProviderUnavailable`` rather than returning +an empty list. The distinction matters in a multi-provider fan-out: an +empty list would read as "EasyEDA has no such part", which is a claim +this provider is in no position to make. +""" + +from __future__ import annotations + +from typing import Any + +from eda_agent.libimport.providers.base import ( + PartHit, + ProviderError, + ProviderUnavailable, +) + +__all__ = ["EasyEdaProvider"] + + +class EasyEdaProvider: + """Fetch-by-id against EasyEDA's component API.""" + + name = "easyeda" + description = ( + "EasyEDA / LCSC component data, no login. Fetch by LCSC part " + "number works; SEARCH is unavailable because the upstream " + "endpoint was withdrawn (not a credentials problem). Get the " + "part number from LCSC in a browser.") + + #: What a fetch yields, and which EDA tools can consume it. Stated + #: because it decides whether a hit is usable at all: this server + #: has an EasyEDA->Altium converter but NO KiCad->Altium path, so a + #: KiCad-only provider is a dead end for an Altium user. + formats = ("easyeda_json",) + usable_in = ("altium", "kicad") + + def search(self, query: str, limit: int = 20) -> list[PartHit]: + raise ProviderUnavailable( + "EasyEDA/LCSC no longer expose a public part-search endpoint, " + "so this provider cannot search. Fetching a known LCSC part " + "number still works.") + + def fetch(self, part_id: str) -> dict[str, Any]: + from eda_agent.libimport.easyeda.fetch import ( + EasyEdaFetchError, + fetch_component_json, + ) + + try: + return fetch_component_json(str(part_id)) + except EasyEdaFetchError as exc: + raise ProviderError(f"easyeda fetch {part_id}: {exc}") from exc + + def describe(self, part_id: str) -> PartHit: + from eda_agent.libimport.easyeda import parse_component + + comp = parse_component(self.fetch(part_id)) + return PartHit( + provider=self.name, + part_id=str(part_id), + mpn=comp.mpn, + manufacturer=comp.manufacturer, + package=comp.package, + description=comp.description, + datasheet=comp.datasheet, + # EasyEDA states no origin for its geometry, and saying so is + # itself useful: an imported footprint is a vendor drawing, + # not a datasheet-verified land pattern. + provenance="", + license="", + extra={"warnings": list(comp.warnings)}, + ) diff --git a/src/eda_agent/libimport/providers/kicad_local.py b/src/eda_agent/libimport/providers/kicad_local.py new file mode 100644 index 0000000..b0c71fb --- /dev/null +++ b/src/eda_agent/libimport/providers/kicad_local.py @@ -0,0 +1,400 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""The KiCad libraries already installed on this machine. + +Worth having alongside the network registries for reasons none of them +can match: it needs no network, no login and no third party, it cannot +be withdrawn the way EasyEDA's search endpoint was, and its parts are +the ones KiCad itself ships and maintains. + +A fetch resolves the symbol's own ``Footprint`` property against the +installed ``.pretty`` libraries, so most hits come back as a whole part +rather than a symbol on its own. That matters on Altium, where a symbol +with no land pattern is half a part and the caller would otherwise have +no way to know one was available. Measured across all 222 libraries +shipped with KiCad 10.0.1: 18003 of 22728 symbols (79 percent) carry +such a reference. + +Where the standard libraries genuinely say nothing, so does this. Most +entries carry no MPN and no manufacturer, a generic symbol records no +footprint at all, and each of those comes back blank rather than +guessed. A reference that names a library which is not installed is +reported as exactly that, since "you do not have it" and "there is +none" call for different responses. + +Discovery order (first hit wins, all overridable), symbols and +footprints resolved independently because either can be moved alone: + 1. ``EDA_AGENT_KICAD_SYMBOL_DIR`` / ``EDA_AGENT_KICAD_FOOTPRINT_DIR`` + 2. ``KICAD_SYMBOL_DIR`` / ``KICAD_FOOTPRINT_DIR`` (KiCad's own) + 3. the standard install locations per platform + +Search parsing is a deliberate shallow scan for ``(symbol "NAME"`` at +the start of a line. Fully parsing 222 s-expression files to answer one +query would be slow and buys nothing: the symbol NAME is all a search +needs. A fetch does parse properly, but only the one library named. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from typing import Any, Optional + +from eda_agent.libimport.providers.base import ( + PartHit, + ProviderError, + ProviderUnavailable, +) + +__all__ = ["KicadLocalProvider"] + +#: Top-level symbol definitions only. Nested unit symbols are indented +#: and named ``NAME_0_1`` / ``NAME_1_1``, which are not parts. +_SYMBOL_RE = re.compile(r'^\s{0,2}\(symbol\s+"([^"]+)"', re.M) + +_WINDOWS_ROOTS = ( + r"C:\Program Files\KiCad", + r"C:\Program Files (x86)\KiCad", +) +_POSIX_ROOTS = ( + "/usr/share/kicad", + "/usr/local/share/kicad", + "/Applications/KiCad/KiCad.app/Contents/SharedSupport", +) + + +def _library_dir(kind: str, env_vars: tuple[str, ...]) -> Optional[Path]: + for var in env_vars: + raw = os.environ.get(var) + if raw and Path(raw).is_dir(): + return Path(raw) + + candidates: list[Path] = [] + for root in _WINDOWS_ROOTS: + base = Path(root) + if base.is_dir(): + # Versioned subdirectories (10.0, 9.0, ...); newest first so + # a machine with several installs uses the current one. + for version in sorted(base.iterdir(), reverse=True): + candidates.append(version / "share" / "kicad" / kind) + for root in _POSIX_ROOTS: + candidates.append(Path(root) / kind) + + for path in candidates: + if path.is_dir(): + return path + return None + + +def _symbol_dir() -> Optional[Path]: + return _library_dir( + "symbols", ("EDA_AGENT_KICAD_SYMBOL_DIR", "KICAD_SYMBOL_DIR")) + + +def _footprint_dir() -> Optional[Path]: + """Where the ``.pretty`` footprint libraries live. + + Searched independently of the symbols rather than derived from them, + because either can be overridden on its own. + """ + return _library_dir( + "footprints", + ("EDA_AGENT_KICAD_FOOTPRINT_DIR", "KICAD_FOOTPRINT_DIR")) + + +def _model_dir() -> Optional[Path]: + """Where the shipped STEP/WRL 3D models live.""" + return _library_dir( + "3dmodels", + ("EDA_AGENT_KICAD_3DMODEL_DIR", "KICAD_3DMODEL_DIR")) + + +def resolve_model_3d(ref: str) -> Optional[Path]: + """Resolve a footprint's 3D model reference to a file here. + + KiCad writes ``${KICAD10_3DMODEL_DIR}/Lib.3dshapes/Name.step``. The + variable name carries the major version, so it is stripped rather + than matched: the point is the path under the model root, and + hard-coding one version would break on the next release. + + Worth resolving because it completes the part. Altium's linker takes + STEP and KiCad ships STEP, so a local hit can carry a real 3D body + rather than a footprint with nothing above the board. Prefers STEP + over any sibling: Altium cannot load KiCad's WRL. + """ + text = str(ref or "").strip().replace("\\", "/") + if not text: + return None + root = _model_dir() + if root is None: + return None + # Drop a leading ${...} variable, or an absolute prefix ending at + # the model root, leaving "Lib.3dshapes/Name.step". + tail = re.sub(r"^\$\{[^}]*\}/?", "", text) + if tail == text: + marker = "3dmodels/" + idx = text.lower().find(marker) + tail = text[idx + len(marker):] if idx >= 0 else text + tail = tail.lstrip("/") + if not tail: + return None + candidate = root / tail + try: + if not candidate.resolve().is_relative_to(root.resolve()): + return None + except OSError: + return None + if candidate.is_file() and candidate.suffix.lower() in (".step", ".stp"): + return candidate + # A .wrl reference usually has a .step sibling, which is the one + # Altium can actually load. + for suffix in (".step", ".stp"): + sibling = candidate.with_suffix(suffix) + if sibling.is_file(): + return sibling + return None + + +def _resolve_footprint(ref: str) -> Optional[Path]: + """Turn a symbol's ``Library:Name`` reference into a real file. + + This is what makes a hit a whole part instead of half of one: 18003 + of the 22728 symbols shipped with KiCad 10.0.1 carry such a + reference, and without resolving it an Altium import produces a + symbol with no land pattern. + + Returns None rather than guessing when the reference is blank, + malformed, or names something not installed. A near-miss footprint + would be worse than none, since it would look converted. + """ + if ":" not in (ref or ""): + return None + lib, _, name = ref.partition(":") + root = _footprint_dir() + if root is None or not lib or not name: + return None + path = root / f"{lib}.pretty" / f"{name}.kicad_mod" + # The reference comes out of a library file, so treat it as input: + # a crafted "../.." must not escape the footprint root. + try: + if not path.resolve().is_relative_to(root.resolve()): + return None + except OSError: + return None + return path if path.is_file() else None + + +class KicadLocalProvider: + """Search the symbol libraries KiCad installed locally.""" + + name = "kicad_local" + description = ( + "The libraries installed with KiCad on this machine. Offline, no " + "login, no third party. A fetch resolves the symbol's footprint " + "reference against the installed .pretty libraries, so most hits " + "are a whole part; a generic symbol that records no footprint " + "says so. Most entries carry no MPN or datasheet, and those are " + "reported blank rather than guessed. KiCad format, usable in " + "Altium via lib_kicad_import.") + + formats = ("kicad_sym",) + usable_in = ("kicad", "altium") + + def __init__(self) -> None: + self._cache: Optional[list[tuple[str, str]]] = None + self._resolved: dict[str, dict[str, Any]] = {} + + def _index(self) -> list[tuple[str, str]]: + """(library stem, symbol name) for every installed symbol.""" + if self._cache is not None: + return self._cache + root = _symbol_dir() + if root is None: + raise ProviderUnavailable( + "no KiCad symbol library directory found; set " + "EDA_AGENT_KICAD_SYMBOL_DIR if KiCad is installed " + "somewhere non-standard") + out: list[tuple[str, str]] = [] + for path in sorted(root.glob("*.kicad_sym")): + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + for name in _SYMBOL_RE.findall(text): + # Skip the per-unit sub-symbols (NAME_0_1, NAME_1_1). + if re.search(r"_\d+_\d+$", name): + continue + out.append((path.stem, name)) + self._cache = out + return out + + def search(self, query: str, limit: int = 20) -> list[PartHit]: + needle = str(query or "").strip().lower() + if not needle: + return [] + hits: list[PartHit] = [] + for lib, symbol in self._index(): + if needle not in symbol.lower() and needle not in lib.lower(): + continue + hits.append(PartHit( + provider=self.name, + part_id=f"{lib}:{symbol}", + mpn=symbol, + description=f"KiCad library {lib}", + # Blank on purpose: the standard libraries do not carry + # a manufacturer, MPN or datasheet for most symbols, and + # inventing them would be worse than admitting it. + provenance=f"KiCad standard library {lib}", + license="CC-BY-SA-4.0 with exception", + extra={"library": lib, "symbol": symbol}, + )) + if len(hits) >= max(1, int(limit)): + break + return hits + + def _resolve(self, part_id: str) -> dict[str, Any]: + """Locate a symbol and everything derivable from it. + + Shared by ``fetch`` and ``describe``, and memoised, because + ``part_fetch`` calls both for the same part: these libraries run + to several megabytes and the parse is the whole cost of + answering, so doing it twice would double the call for nothing. + + The memo lives on the instance, and a fresh provider is built per + ``available_providers()`` call, so it cannot outlive the request + and go stale against an edited library. + """ + pid = str(part_id or "").strip() + cached = self._resolved.get(pid) + if cached is not None: + return cached + if ":" not in pid: + raise ProviderError( + f"expected 'library:symbol', got {part_id!r}") + lib, symbol = pid.split(":", 1) + root = _symbol_dir() + if root is None: + raise ProviderUnavailable("no KiCad symbol library directory") + path = root / f"{lib}.kicad_sym" + # The library name is caller input; keep it inside the root. + if not path.resolve().is_relative_to(root.resolve()): + raise ProviderError(f"refusing path outside library root: {pid}") + if not path.is_file(): + raise ProviderError(f"no such library: {lib}") + # Resolve the symbol's own footprint reference into a real file. + # A symbol on its own is half a part: converted to Altium it + # would arrive with no land pattern, and the caller would have + # no way to know one was available. + ref, fp_path, datasheet, description = "", None, "", "" + units = 1 + try: + from eda_agent.libimport.kicad.reader import read_kicad_symbol + + comp = read_kicad_symbol( + path.read_text(encoding="utf-8", errors="replace"), + name=symbol) + ref = comp.footprint_ref + datasheet, description = comp.datasheet, comp.description + units = comp.unit_count + fp_path = _resolve_footprint(ref) + except (OSError, ValueError) as exc: + # Locating the symbol still succeeded; say what did not. + description = f"(could not read footprint reference: {exc})" + # The 3D body completes the part. Read from the footprint rather + # than the symbol, which is where KiCad records it. + model_3d = "" + if fp_path is not None: + try: + from eda_agent.libimport.kicad.reader import ( + read_kicad_footprint, + ) + + fp_comp = read_kicad_footprint( + fp_path.read_text(encoding="utf-8", errors="replace")) + resolved = resolve_model_3d( + fp_comp.footprint.model_3d_ref) + model_3d = str(resolved) if resolved else "" + except (OSError, ValueError): + model_3d = "" + + found = {"lib": lib, "symbol": symbol, "path": path, "ref": ref, + "fp_path": fp_path, "datasheet": datasheet, + "description": description, "units": units, + "model_3d": model_3d} + self._resolved[pid] = found + return found + + def describe(self, part_id: str) -> PartHit: + """The normalised view, which is what makes sources comparable. + + Populated from the symbol itself rather than from the search + index, so the datasheet and description are the real ones where + the library records them. Manufacturer and MPN stay blank: a + symbol name is not a part number, and treating it as one would + put a fabricated MPN into a BOM. + """ + found = self._resolve(part_id) + return PartHit( + provider=self.name, + part_id=str(part_id), + mpn="", + description=found["description"], + datasheet=found["datasheet"], + package=found["ref"].partition(":")[2], + provenance=f"KiCad standard library {found['lib']}", + license="CC-BY-SA-4.0 with exception", + extra={"library": found["lib"], "symbol": found["symbol"], + "footprint_ref": found["ref"]}, + ) + + def fetch(self, part_id: str) -> dict[str, Any]: + found = self._resolve(part_id) + lib, symbol, path = found["lib"], found["symbol"], found["path"] + ref, fp_path = found["ref"], found["fp_path"] + datasheet, description = found["datasheet"], found["description"] + + out: dict[str, Any] = { + "library": lib, + "symbol": symbol, + "path": str(path), + "symbol_path": str(path), + "footprint_ref": ref, + "footprint_path": str(fp_path) if fp_path else "", + "datasheet": datasheet, + "description": description, + "unit_count": found["units"], + "model_3d_path": found["model_3d"], + "import_with": "lib_kicad_import", + } + if found["units"] > 1: + # Say it before the import rather than after: converting one + # unit and stopping leaves most of the part behind, and + # nothing else in the result would reveal that. + out["units_note"] = ( + f"This part has {found['units']} units; lib_kicad_import " + f"builds all of them as one Altium multi-part component " + f"in a single call. Pass unit=1..{found['units']} only " + f"if you deliberately want just one sub-part.") + if fp_path is not None: + out["note"] = ( + "Whole part: pass symbol_path, footprint_path and " + "symbol_name to lib_kicad_import for Altium, or use both " + "files as-is on KiCad. symbol_name is required because " + "this library holds many symbols.") + elif ref: + # A reference that does not resolve is a different problem + # from no reference at all, and only one of them is worth + # chasing (an uninstalled library, usually). + out["note"] = ( + f"Symbol only. It names footprint {ref!r}, which is not " + f"installed here, so no land pattern was found. Supply " + f"footprint_path yourself, or convert the symbol alone " + f"with lib_kicad_import(symbol_path=..., symbol_name=...).") + else: + out["note"] = ( + "Symbol only: this entry records no footprint, which is " + "normal for the generic symbols in the standard " + "libraries. Pick a land pattern from the manufacturer " + "datasheet rather than assuming one.") + return out diff --git a/src/eda_agent/libimport/providers/partreel.py b/src/eda_agent/libimport/providers/partreel.py new file mode 100644 index 0000000..eea2a5f --- /dev/null +++ b/src/eda_agent/libimport/providers/partreel.py @@ -0,0 +1,301 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""PartReel: an open, no-login registry of verified KiCad parts. + +DISCLOSURE, because it affects how much weight to give this source: the +registry was proposed to this project by the person who runs it (issue +#12). It is included on equal footing with every other provider and is +NOT a default, which is the point of the provider layer. + +Measured against the live service rather than taken from the proposal: + +* ``/api/v1/parts.json`` returns the WHOLE index, 21,657 parts, 11.4 MB. +* ``/api/v1/parts/.json`` returns detail: ``mpn_pattern``, + ``datasheet``, ``dimensions_source``, ``license``, ``files``, + ``provenance``, ``tier``, ``verified``. +* There is NO server-side search. ``?q=`` and ``?search=`` are accepted + and silently ignored, returning the full index either way. + +That last point drives the design here: searching means pulling the +index and filtering locally, so the index is cached on disk. Without +caching, every query would move 11.4 MB. + +The index records carry only ``id/name/category/family/manufacturer/ +keywords/pins/verified``; the provenance fields live on the per-part +detail, so a hit is enriched lazily and only when asked for. +""" + +from __future__ import annotations + +import os +import time as _time +from typing import Any + +from eda_agent.libimport._names import safe_filename +from eda_agent.libimport.providers._http import ( + FetchError, + get_bytes, + get_json_cached, +) +from eda_agent.libimport.providers.base import ( + PartHit, + ProviderError, + ProviderUnavailable, +) + +__all__ = ["PartReelProvider"] + +#: Parsed index memo, shared across provider instances because +#: available_providers() builds a fresh provider per call. +_MEMO: Any = None +_MEMO_AT: float = 0.0 + + +#: The registry this client is a client OF, exactly as the Digi-Key +#: client points at Digi-Key and the Mouser client at Mouser. Verified +#: live: ``/api/v1/parts.json`` serves 21,657 parts over plain HTTP with +#: no auth and no key. +#: +#: A default here is not a preference. The neutrality this module cares +#: about is about RANKING, and that is enforced elsewhere and by tests: +#: this source is registered alphabetically among the others, is queried +#: in the same fan-out, is never consulted as a fallback when another +#: source comes back thin, and has no path to the front of a result +#: list. Naming its own endpoint is what makes it a working peer rather +#: than a switch nobody turns on. +_DEFAULT_BASE = "https://partreel.com" + + +def _base() -> str: + """The registry to query, overridable. + + ``PARTS_REGISTRY_URL`` points this client at any API-compatible + registry, which is the reason the URL is a constant rather than + hardcoded at each call site: the API shape is the contract, not the + host. Unset, it queries the registry it was written against. + """ + return (os.environ.get("PARTS_REGISTRY_URL", "").strip().rstrip("/") + or _DEFAULT_BASE) + + +def _hosts() -> set[str]: + from urllib.parse import urlparse + + host = (urlparse(_base()).hostname or "").lower() + allowed = {host} if host else set() + extra = os.environ.get("PARTS_REGISTRY_ASSET_HOSTS", "") + allowed |= {h.strip().lower() for h in extra.split(",") if h.strip()} + return allowed + + + +def _declared_version(data: bytes) -> int: + """The ``(version NNNNNNNN)`` a KiCad s-expression file declares.""" + import re + + m = re.search(rb"\(version\s+(\d{8})\)", data[:400]) + return int(m.group(1)) if m else 0 + + +def _local_kicad_version() -> int: + """Format version the INSTALLED KiCad writes, read from its own libs. + + Comparing against the local install is the only meaningful check: + a file is not "too new" in the abstract, only relative to the KiCad + that has to open it. + """ + from eda_agent.libimport.providers.kicad_local import _symbol_dir + + root = _symbol_dir() + if root is None: + return 0 + for path in sorted(root.glob("*.kicad_sym"))[:1]: + try: + return _declared_version(path.read_bytes()[:400]) + except OSError: + return 0 + return 0 + + +class PartReelProvider: + """Search and fetch from a PartReel-compatible registry.""" + + name = "partreel" + description = ( + "Open registry of verified KiCad parts, no login. Run by a third " + "party (proposed in issue #12 by its operator). Point " + "PARTS_REGISTRY_URL at any API-compatible registry to substitute " + "another. Yields KiCad files, usable in Altium via " + "lib_kicad_import.") + + formats = ("kicad_mod", "kicad_sym", "glb") + usable_in = ("kicad", "altium") + + #: The index is large and changes slowly; a day is a reasonable + #: bound on staleness against re-downloading 11.4 MB per query. + index_ttl_s = 86400.0 + + def _raw_index(self) -> list[dict[str, Any]]: + url = f"{_base()}/api/v1/parts.json" + try: + data = get_json_cached(url, _hosts(), self.index_ttl_s) + except FetchError as exc: + raise ProviderUnavailable(f"partreel index: {exc}") from exc + if isinstance(data, dict): + for key in ("parts", "data", "results"): + if isinstance(data.get(key), list): + return data[key] + return [] + return data if isinstance(data, list) else [] + + def _index(self) -> list[tuple[str, str, str, str, str]]: + """Compact searchable index: (id, name, manufacturer, family, hay). + + The disk cache alone still costs ~0.2s per search, because it + re-parses 11.8 MB of JSON every time and an MCP server answers + many searches per session. Holding the PARSED index in memory + would cost far more RAM than it saves, so this keeps only the + five fields a search reads, with the haystack pre-lowered. + + Module-level rather than per-instance: available_providers() + constructs a fresh provider for every call, so instance state + would never be reused. + """ + global _MEMO, _MEMO_AT + now = _time.time() + if _MEMO is not None and (now - _MEMO_AT) < self.index_ttl_s: + return _MEMO + + compact: list[tuple[str, str, str, str, str]] = [] + for row in self._raw_index(): + if not isinstance(row, dict): + continue + name = str(row.get("name", "")) + manufacturer = str(row.get("manufacturer", "")) + family = str(row.get("family", "")) + keywords = row.get("keywords") + hay = " ".join([ + name, manufacturer, family, str(row.get("category", "")), + " ".join(str(k) for k in keywords) + if isinstance(keywords, list) else "", + ]).lower() + compact.append((str(row.get("id", "")), name, manufacturer, + family, hay)) + _MEMO, _MEMO_AT = compact, now + return compact + + def search(self, query: str, limit: int = 20) -> list[PartHit]: + needle = str(query or "").strip().lower() + if not needle: + return [] + hits: list[PartHit] = [] + for part_id, name, manufacturer, family, hay in self._index(): + if needle not in hay: + continue + hits.append(PartHit( + provider=self.name, + part_id=part_id, + mpn=name, + manufacturer=manufacturer, + description=family, + )) + if len(hits) >= max(1, int(limit)): + break + return hits + + def fetch(self, part_id: str) -> dict[str, Any]: + pid = str(part_id or "").strip() + if not pid: + raise ProviderError("empty part id") + # Guard the path segment: an id is registry data, and a slash or + # traversal in it must not reshape the URL. + if "/" in pid or ".." in pid: + raise ProviderError(f"refusing suspicious part id: {pid!r}") + url = f"{_base()}/api/v1/parts/{pid}.json" + try: + data = get_json_cached(url, _hosts(), self.index_ttl_s) + except FetchError as exc: + raise ProviderError(f"partreel fetch {pid}: {exc}") from exc + if not isinstance(data, dict): + raise ProviderError(f"partreel returned no detail for {pid}") + return data + + + #: Artefacts worth downloading, and the extension each must have. + #: An allowlist rather than "whatever the payload names": the file + #: URLs are registry data, and writing an arbitrary extension from + #: untrusted JSON is how a download turns into an executable. + DOWNLOADABLE = { + "footprint": ".kicad_mod", + "symbol": ".kicad_sym", + "model_3d": ".glb", + } + + def download(self, part_id: str, dest_dir) -> dict[str, str]: + """Fetch this part's library files into ``dest_dir``. + + Returns ``{kind: written path}``. Only the kinds in + :attr:`DOWNLOADABLE` are taken, and each is written with the + extension this code expects rather than one derived from the + URL: the registry names those files, and a name is not a + promise about content. + """ + from pathlib import Path + + detail = self.fetch(part_id) + files = detail.get("files") + if not isinstance(files, dict): + return {} + + out = Path(dest_dir) + out.mkdir(parents=True, exist_ok=True) + # The id is registry data and becomes a filename. + stem = safe_filename(detail.get("id") or part_id) + + written: dict[str, str] = {} + for kind, suffix in self.DOWNLOADABLE.items(): + url = files.get(kind) + if not isinstance(url, str) or not url: + continue + try: + data = get_bytes(url, _hosts()) + except FetchError as exc: + # One missing artefact must not lose the others. + written[f"{kind}_error"] = str(exc) + continue + path = out / f"{stem}{suffix}" + path.write_bytes(data) + written[kind] = str(path) + + # A downloaded file that the installed KiCad cannot open is + # not a successful download. Observed live: the registry + # ships format 20260206 while KiCad 10.0.1 writes 20251024, + # and its symbol parser refuses the newer file outright + # ("Unable to load library"). The footprint parser is more + # tolerant, so this is checked per file rather than assumed. + declared = _declared_version(data[:400]) + local = _local_kicad_version() + if declared and local and declared > local: + written[f"{kind}_warning"] = ( + f"file declares KiCad format {declared} but the " + f"installed KiCad writes {local}; it may refuse to " + f"open this file. Upgrade KiCad, or use the part's " + f"other formats.") + return written + + def describe(self, part_id: str) -> PartHit: + """A hit enriched with the provenance the detail endpoint holds.""" + d = self.fetch(part_id) + files = d.get("files") if isinstance(d.get("files"), dict) else {} + return PartHit( + provider=self.name, + part_id=str(d.get("id", part_id)), + mpn=str(d.get("mpn_pattern") or d.get("name", "")), + manufacturer=str(d.get("manufacturer", "")), + description=str(d.get("description") or d.get("family", "")), + datasheet=str(d.get("datasheet", "")), + provenance=str(d.get("dimensions_source", "")), + license=str(d.get("license", "")), + extra={"files": files, "tier": d.get("tier"), + "verified": d.get("verified")}, + ) diff --git a/src/eda_agent/libimport/providers/public_libraries.py b/src/eda_agent/libimport/providers/public_libraries.py new file mode 100644 index 0000000..0b18008 --- /dev/null +++ b/src/eda_agent/libimport/providers/public_libraries.py @@ -0,0 +1,280 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Openly published KiCad libraries, no login and no key. + +Exists so that ``partreel`` is not the only source that answers without +a credential. A single no-auth provider is a single point of dependence, +and the whole reason this layer fans out is that no one source should be +load-bearing. + +WHAT THIS SERVES. Overwhelmingly FOOTPRINTS: land patterns you can +import when you know the part but not its geometry. That is measured, +not assumed, and it corrects a plausible-sounding guess: Digi-Key's +KiCad repository contains 936 footprints and ZERO symbols, and KiCad's +own footprint repository holds 12,011. Only the JLCPCB library ships +symbols, 20 libraries of them. + +WHY NO KICAD SYMBOLS. Two independent reasons, either sufficient. The +GitHub mirror of ``kicad-symbols`` returns no ``.kicad_sym`` blobs at +all, because the modern layout stores each library as a +``.kicad_symdir`` DIRECTORY of per-symbol files. The canonical host for +that is GitLab, and **gitlab.com/robots.txt carries +``Disallow: /api/v*``**, which is exactly the endpoint an index would +have to walk. So this provider does not touch GitLab. KiCad's symbols +are already served by ``kicad_local``, which reads them off disk with no +network at all. + +RESPECTING THE HOSTS. Every source here is reached through GitHub's +documented API rather than by scraping pages. The client identifies +itself in the User-Agent, which GitHub requires; it builds a whole index +in ONE recursive tree request per repository rather than walking +directories; it caches that index on disk so repeated searches cost +nothing; and it treats HTTP 429 as "back off", never as "no results". +Anonymous GitHub allows 60 requests an hour, and a full index of all +three repositories costs three. +""" + +from __future__ import annotations + +import json +import os +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +from eda_agent.libimport.providers.base import ( + PartHit, + ProviderError, + ProviderUnavailable, +) + +__all__ = ["PublicLibrariesProvider"] + +_TIMEOUT = 25 + +#: How long a cached index stays good. These repositories change over +#: days, not minutes, and a stale footprint is far cheaper than +#: exhausting a 60-per-hour budget on every search. +_CACHE_TTL = 7 * 24 * 3600 + +#: GitHub requires a User-Agent and blocks requests without one. Naming +#: the project rather than impersonating a browser is the honest form, +#: and it lets the host identify this traffic if it ever needs to. +_UA = "eda-agent (https://github.com/salitronic/eda-agent)" + +#: The repositories indexed, each reachable in one recursive tree call. +#: `licence` is what the GitHub API reports, NOT what this project +#: believes: two of the three report NOASSERTION, meaning they carry +#: terms the API could not classify, and that is surfaced rather than +#: smoothed into a confident-looking answer. +_SOURCES: tuple[dict[str, Any], ...] = ( + { + "repo": "KiCad/kicad-footprints", + "branch": "master", + "licence": "NOASSERTION (see the repository)", + "note": "KiCad's official footprint library", + }, + { + "repo": "Digi-Key/digikey-kicad-library", + "branch": "master", + "licence": "NOASSERTION (see the repository)", + "note": "Digi-Key's published footprints; no symbols in this repo", + }, + { + "repo": "CDFER/JLCPCB-Kicad-Library", + "branch": "main", + "licence": "MIT", + "note": "JLCPCB basic and preferred parts, symbols and footprints", + }, +) + +_TREE_URL = "https://api.github.com/repos/{repo}/git/trees/{branch}?recursive=1" +_RAW_URL = "https://raw.githubusercontent.com/{repo}/{branch}/{path}" + + +def _cache_dir() -> Path: + configured = os.environ.get("EDA_AGENT_CACHE_DIR", "").strip() + root = Path(configured) if configured else Path.home() / ".cache" + return root / "eda-agent" / "public-libraries" + + +def _fetch_json(url: str) -> Any: + request = urllib.request.Request(url, headers={"User-Agent": _UA}) + try: + with urllib.request.urlopen(request, timeout=_TIMEOUT) as response: + return json.loads(response.read().decode("utf-8", "replace")) + except urllib.error.HTTPError as exc: + if exc.code in (403, 429): + # GitHub answers an exhausted anonymous budget with 403 and a + # rate-limit header, so the two codes mean the same thing + # here. Never an empty result: the part may well exist. + raise ProviderUnavailable( + "GitHub rate-limited this client (anonymous requests are " + "capped at 60 per hour). The cached index is used when " + "present; this is not evidence the part does not exist." + ) from exc + raise ProviderError( + f"GitHub returned HTTP {exc.code} for the library index") from exc + except (urllib.error.URLError, TimeoutError, OSError) as exc: + raise ProviderUnavailable( + f"cannot reach GitHub to index the public libraries: {exc}" + ) from exc + except ValueError as exc: + raise ProviderError("GitHub returned a non-JSON index") from exc + + +def _index_one(source: dict[str, Any]) -> list[dict[str, str]]: + """Every library file in one repository, from a single request.""" + payload = _fetch_json(_TREE_URL.format(repo=source["repo"], + branch=source["branch"])) + entries = [] + for node in payload.get("tree", []): + if node.get("type") != "blob": + continue + path = node.get("path", "") + if not path.endswith((".kicad_sym", ".kicad_mod")): + continue + # Archived directories are kept out of results rather than + # filtered by the caller: an archived part looks identical to a + # current one in a hit, and shipping a withdrawn land pattern is + # exactly the failure this project audits for elsewhere. + if "rchive" in path: + continue + entries.append({ + "repo": source["repo"], + "branch": source["branch"], + "path": path, + "licence": source["licence"], + }) + if payload.get("truncated"): + # Silence here would look like a small repository. Say it. + entries.append({"repo": source["repo"], "branch": source["branch"], + "path": "", "licence": source["licence"], + "truncated": "1"}) + return entries + + +def _load_index(refresh: bool = False) -> list[dict[str, str]]: + """The merged index, from disk when fresh enough. + + A cache miss costs one request per repository. A hit costs nothing, + which is what keeps an interactive search inside a 60-per-hour + budget. + """ + path = _cache_dir() / "index.json" + if not refresh and path.is_file(): + try: + age = time.time() - path.stat().st_mtime + if age < _CACHE_TTL: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + # A corrupt cache must not be fatal; fall through and rebuild. + pass + + merged: list[dict[str, str]] = [] + failures: list[str] = [] + for source in _SOURCES: + try: + merged.extend(_index_one(source)) + except ProviderError as exc: + # One unreachable repository must not empty the others. + failures.append(f"{source['repo']}: {exc}") + + if not merged: + # Prefer a stale cache to no answer at all: an outdated land + # pattern the user can audit beats "no such part". + if path.is_file(): + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + pass + raise ProviderUnavailable( + "could not index any public library repository" + + (": " + "; ".join(failures) if failures else "")) + + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(merged), encoding="utf-8") + except OSError: + # An unwritable cache is a performance problem, not a failure. + pass + return merged + + +class PublicLibrariesProvider: + """Openly published KiCad libraries, indexed from GitHub.""" + + name = "public_libraries" + description = ( + "Openly published KiCad libraries (KiCad's own footprints, " + "Digi-Key's footprints, and JLCPCB symbols and footprints). No " + "login and no key. Mostly LAND PATTERNS rather than symbols, " + "which is what you want when the part is chosen but its geometry " + "is not. Indexed through GitHub's documented API, one request " + "per repository, cached on disk for a week.") + + kind = "library" + formats = ("kicad_sym", "kicad_mod") + usable_in = ("kicad", "altium") + + def _entries(self) -> list[dict[str, str]]: + return [e for e in _load_index() if e.get("path")] + + def search(self, query: str, limit: int = 20) -> list[PartHit]: + needle = (query or "").strip().lower() + hits: list[PartHit] = [] + for entry in self._entries(): + path = entry["path"] + stem = path.rsplit("/", 1)[-1] + name = stem.rsplit(".", 1)[0] + if needle and needle not in name.lower(): + continue + is_symbol = path.endswith(".kicad_sym") + hits.append(PartHit( + provider=self.name, + # Repository-qualified so the same footprint name in two + # libraries stays addressable. + part_id=f"{entry['repo']}::{path}", + mpn="", + description=( + f"{'symbol library' if is_symbol else 'footprint'} " + f"from {entry['repo']}"), + package="" if is_symbol else name, + provenance=f"openly published library: {entry['repo']}", + license=entry.get("licence", ""), + extra={"repo": entry["repo"], "branch": entry["branch"], + "path": path, + "format": "kicad_sym" if is_symbol else "kicad_mod"}, + )) + if len(hits) >= limit: + break + return hits + + def fetch(self, part_id: str) -> dict[str, Any]: + repo, _, path = (part_id or "").partition("::") + if not repo or not path: + raise ProviderError( + f"part_id must be '::', got {part_id!r}") + for entry in self._entries(): + if entry["repo"] == repo and entry["path"] == path: + url = _RAW_URL.format(repo=repo, branch=entry["branch"], + path=path) + return { + "provider": self.name, + "part_id": part_id, + "repo": repo, + "path": path, + "url": url, + "license": entry.get("licence", ""), + "format": ("kicad_sym" if path.endswith(".kicad_sym") + else "kicad_mod"), + "files": {}, + "note": ( + "Convert with lib_kicad_import. The licence shown " + "is what the repository declares; NOASSERTION " + "means it carries terms that were not classified, " + "not that it is unrestricted."), + } + raise ProviderError(f"no {path!r} in {repo!r}") diff --git a/src/eda_agent/tools/parts.py b/src/eda_agent/tools/parts.py new file mode 100644 index 0000000..e120ac0 --- /dev/null +++ b/src/eda_agent/tools/parts.py @@ -0,0 +1,247 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Provider-neutral part search and fetch. + +Two tools rather than one per source, so adding a provider never grows +the tool surface (see the tool-count concern in issue #10), and so no +single source occupies a privileged name. +""" + +from __future__ import annotations + +from typing import Any + +__all__ = ["register_parts_tools"] + + +def register_parts_tools(mcp): + """Register the provider-neutral part tools with the MCP server.""" + + @mcp.tool() + async def part_search( + query: str = "", + limit_per_provider: int = 20, + ) -> dict[str, Any]: + """Search EVERY enabled part provider and merge the results. + + No provider is a default and none is preferred: all are queried + equally, hits carry the provider that found them, and the merged + order is alphabetical by provider then part. That ordering is + NOT relevance, so do not read the first hit as the best one. + + Each provider reports its own status. A provider that cannot + answer says why (endpoint withdrawn, software missing) instead + of returning nothing, because "unavailable" and "no such part" + are different answers and only one means stop looking. + + Call with an empty query to list the providers and what they are, + including who operates them. + + TWO KINDS OF SOURCE, merged but never conflated. A `library` + provider yields GEOMETRY: a symbol or footprint you can place. A + `catalogue` provider (the distributors and aggregators) yields + IDENTITY: the manufacturer part number, the datasheet, what is + in stock, and NO geometry at all. Every hit carries its `kind`, + because finding out that a distributor hit has no symbol after + choosing the part is the expensive way to learn it. + + The two answer different questions rather than ranking against + each other: a catalogue tells you WHICH part to use and hands + you the datasheet every check here measures against, and a + library tells you whether you can draw it. + + NOTE on maturity="offline": in this catalog that means "needs no + Altium", which is true here. It does NOT mean "needs nothing": + the remote providers want the internet. With no network the + search still succeeds on whatever local providers are enabled + (kicad_local reads libraries already on disk) and reports the + remote ones as unavailable, so a degraded answer is still a + useful one. + + DATASHEET DISCIPLINE: a hit is a lead, not a verified part. The + `provenance` and `license` fields say what the provider claims + about where its geometry came from, and a blank means unknown, + not permissive. Audit any imported footprint against the + manufacturer land pattern with + ``lib_audit_footprint_vs_datasheet`` before trusting it. + + Args: + query: substring matched against part name, manufacturer and + keywords. Empty lists the providers instead. + limit_per_provider: cap per source, so one large registry + cannot crowd out the others. + + Returns: + ``{"ok", "count", "providers": {name: status}, "hits": [...], + "by_mpn": [...]}`` or, for an empty query, + ``{"ok", "providers": [...]}``. + + ``by_mpn`` correlates the same part across sources, so you + can see which providers carry it and what each states about + provenance and license. Cosmetic spelling differences fold + together; WILDCARD part numbers do not, because KiCad's + ``...C8Tx`` is a family placeholder rather than a spelling + of ``...C8T6``, and merging them would assert an equivalence + that is not safe to assume. + """ + from eda_agent.libimport.providers import ( + available_providers, + search_all, + ) + + if not str(query).strip(): + return { + "ok": True, + "providers": [ + { + "name": p.name, + "description": p.description, + # "library" = geometry you can place. + # "catalogue" = identity and a datasheet, with + # nothing to import. + "kind": getattr(p, "kind", "library"), + # False = the endpoint was probed live but the + # request and response shapes have never been + # exercised with a real credential. Stated, not + # assumed. + "verified_live": bool( + getattr(p, "verified_live", True)), + "formats": list(getattr(p, "formats", ())), + # Which EDA tool can actually consume a fetch. + # There is an EasyEDA->Altium converter here but + # NO KiCad->Altium path, so a kicad-only source + # is a dead end for an Altium user and should + # say so before they spend time on it. + "usable_in": list(getattr(p, "usable_in", ())), + } + for p in available_providers() + ], + "note": ("No provider is a default; part_search queries " + "all of them equally. Select a subset with " + "EDA_AGENT_PART_PROVIDERS."), + } + + result = search_all(query, limit_per_provider) + result["ok"] = True + if not result["count"]: + # Distinguish "everything answered and found nothing" from + # "nothing could answer", which look identical otherwise. + reachable = [n for n, s in result["providers"].items() + if s.get("ok")] + result["note"] = ( + f"no matches from {len(reachable)} reachable provider(s)" + if reachable else + "NO provider could answer; this is not evidence the part " + "does not exist") + return result + + @mcp.tool() + async def part_fetch( + part_id: str, + provider: str = "", + download_dir: str = "", + ) -> dict[str, Any]: + """Fetch one part's detail, by the ``ref`` a search returned. + + Pass the ``ref`` straight through: it is ``provider:part_id``, + so the source travels with the id and the merged results behave + like one catalogue. ``part_search`` puts a ``ref`` on every hit. + + The source is still explicit, never inferred. Nothing here picks + a provider for you: a bare id with no ``provider`` and no + recognised prefix is refused rather than guessed at, because + guessing is how one source quietly becomes the answer to + everything. ``provider`` may still be given separately, and wins + over a prefix if both are present. + + Set ``download_dir`` to also write the provider's library files + there, when it offers any. Off by default because it writes to + disk, and a fetch should not do that unasked. + + Downloaded files are checked against the KiCad installed on this + machine: a registry may publish a NEWER s-expression format than + the local KiCad can open (observed live, format 20260206 against + KiCad 10.0.1's 20251024, where the symbol parser refuses the file + outright). Any such file comes back with a ``*_warning`` entry + rather than looking like a clean download. + + Returns the provider's own detail plus a normalised summary + (mpn, manufacturer, datasheet, provenance, license) where the + provider supplies one, and ``files`` when a download was asked + for. + """ + from eda_agent.libimport.providers import ( + ProviderError, + ProviderUnavailable, + available_providers, + get_provider, + ) + + # Unpack a "provider:part_id" ref. Split on the FIRST colon and + # only when the prefix names a REGISTERED provider: part ids + # legitimately contain colons ("Device:R", + # "Lib.SchLib::Comp"), so an unconditional split would corrupt + # them. An explicit provider argument wins. + if not provider: + prefix, sep, rest = (part_id or "").partition(":") + if sep and rest: + known = {p.name for p in available_providers()} + if prefix in known: + provider, part_id = prefix, rest + + if not provider: + return { + "ok": False, + "reason": ( + f"cannot tell which source {part_id!r} came from. Pass " + "the ref from part_search (it looks like " + "'provider:part_id'), or name the provider. This is " + "not guessed: picking a source would make one of them " + "the silent default for every fetch." + ), + } + + try: + source = get_provider(provider) + except ProviderError as exc: + return {"ok": False, "reason": str(exc)} + + try: + detail = source.fetch(part_id) + except ProviderUnavailable as exc: + return {"ok": False, "unavailable": str(exc), + "provider": source.name} + except ProviderError as exc: + return {"ok": False, "reason": str(exc), "provider": source.name} + + summary = None + describe = getattr(source, "describe", None) + if callable(describe): + try: + summary = describe(part_id).to_dict() + except Exception: # noqa: BLE001 - detail already succeeded + summary = None + + result: dict[str, Any] = { + "ok": True, + "provider": source.name, + "part_id": part_id, + "summary": summary, + "detail": detail, + } + + if download_dir: + download = getattr(source, "download", None) + if not callable(download): + result["files"] = {} + result["download_note"] = ( + f"{source.name} offers no downloadable files") + else: + try: + result["files"] = download(part_id, download_dir) + except (ProviderError, OSError) as exc: + # The detail already succeeded; report the download + # failure without discarding what did work. + result["files"] = {} + result["download_error"] = str(exc) + return result diff --git a/tests/test_altium_local_provider.py b/tests/test_altium_local_provider.py new file mode 100644 index 0000000..f7168e6 --- /dev/null +++ b/tests/test_altium_local_provider.py @@ -0,0 +1,139 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""The provider that reads the Altium libraries already on this machine. + +It answers a question no other source can: do I ALREADY own this part? +Every other provider proposes something to import, and importing a part +you already have produces a second symbol with a slightly different +name, which is how a library rots. + +It is also the only source with no network, no login, no rate limit and +no third party, so it keeps working when every remote one is +unreachable. That matters more than it looks: with the registry +unconfigured and EasyEDA's search endpoint withdrawn upstream, the +remote sources answer very little. + +Most assertions here run on a synthetic root rather than the binary +fixture, because ``EDAAgentTest_ICs.SchLib`` is local-only and absent +from a fresh clone. The one test that needs a real OLE library skips +when it is missing, the same way ``test_fileio_schdoc.py`` does. +""" + +from __future__ import annotations + +import os +import pathlib + +import pytest + +from eda_agent.libimport.providers.altium_local import AltiumLocalProvider +from eda_agent.libimport.providers.base import ( + ProviderError, + ProviderUnavailable, +) + +_FIXTURE = (pathlib.Path(__file__).resolve().parents[1] + / "tests" / "integration" / "fixtures" + / "EDAAgentTest_ICs.SchLib") + + +def test_no_libraries_is_unavailable_not_empty(tmp_path, monkeypatch): + """"I found nothing" and "I could not look" are different answers. + + An empty result would tell the caller the part does not exist + anywhere, when in fact this source never ran. + """ + monkeypatch.setenv("EDA_AGENT_ALTIUM_LIBRARIES", str(tmp_path)) + + with pytest.raises(ProviderUnavailable) as excinfo: + AltiumLocalProvider().search("anything") + + assert "EDA_AGENT_ALTIUM_LIBRARIES" in str(excinfo.value), ( + "the message must say how to point it somewhere useful") + assert str(tmp_path) in str(excinfo.value), ( + "it must name where it looked, or the user cannot tell whether " + "the setting took effect") + + +def test_an_unreadable_library_does_not_hide_the_others(tmp_path, + monkeypatch): + """A corrupt or locked file is normal on a shared library drive.""" + (tmp_path / "Broken.SchLib").write_text("not an OLE file", + encoding="utf-8") + monkeypatch.setenv("EDA_AGENT_ALTIUM_LIBRARIES", str(tmp_path)) + + # The broken file is skipped rather than raising: with only a broken + # one present the result is empty, but the call still completes. + assert AltiumLocalProvider().search("x") == [] + + +def test_the_roots_are_configurable_and_not_a_whole_drive_scan(monkeypatch): + """Searching every drive behind the user's back is not acceptable.""" + from eda_agent.libimport.providers import altium_local + + monkeypatch.setenv("EDA_AGENT_ALTIUM_LIBRARIES", + os.pathsep.join(["A:/one", "B:/two"])) + roots = [str(r) for r in altium_local._roots()] + + assert len(roots) == 2 + assert any("one" in r for r in roots) + assert any("two" in r for r in roots) + + +def test_a_malformed_part_id_is_refused_clearly(): + provider = AltiumLocalProvider() + with pytest.raises(ProviderError) as excinfo: + provider.fetch("no-separator-here") + assert "::" in str(excinfo.value), ( + "the error must show the expected shape, not just reject") + + +def test_it_offers_no_download_format(): + """Not an omission: a hit is ALREADY an Altium symbol. + + Advertising a convertible format would invite a caller to import a + part that is by definition already installed. + """ + provider = AltiumLocalProvider() + assert provider.formats == () + assert provider.usable_in == ("altium",) + + +@pytest.mark.skipif(not _FIXTURE.exists(), + reason="needs the local-only binary fixture " + "EDAAgentTest_ICs.SchLib") +def test_it_reads_a_real_schlib(monkeypatch): + """The claim that matters, against a real OLE compound file.""" + monkeypatch.setenv("EDA_AGENT_ALTIUM_LIBRARIES", str(_FIXTURE.parent)) + provider = AltiumLocalProvider() + + hits = provider.search("", limit=20) + assert hits, "the fixture library has components" + names = {h.extra["component"] for h in hits} + assert "TPS54331D" in names + + one = next(h for h in hits if h.extra["component"] == "TPS54331D") + assert one.provider == "altium_local" + assert one.part_id == "EDAAgentTest_ICs.SchLib::TPS54331D" + assert "already installed" in one.provenance + + detail = provider.fetch(one.part_id) + assert detail["component"] == "TPS54331D" + assert detail["library_path"].endswith("EDAAgentTest_ICs.SchLib") + assert detail["files"] == {}, "nothing is downloaded" + + +@pytest.mark.skipif(not _FIXTURE.exists(), + reason="needs the local-only binary fixture") +def test_search_filters_on_name_and_description(monkeypatch): + monkeypatch.setenv("EDA_AGENT_ALTIUM_LIBRARIES", str(_FIXTURE.parent)) + provider = AltiumLocalProvider() + + by_name = provider.search("TPS54331", limit=20) + assert [h.extra["component"] for h in by_name] == ["TPS54331D"] + + # The fixture's SS14 carries "Schottky diode" in its description. + by_description = provider.search("schottky", limit=20) + assert any(h.extra["component"] == "SS14" for h in by_description), ( + "description text must be searchable; a part is often known by " + "what it is rather than by its symbol name") diff --git a/tests/test_distributor_providers.py b/tests/test_distributor_providers.py new file mode 100644 index 0000000..be5e682 --- /dev/null +++ b/tests/test_distributor_providers.py @@ -0,0 +1,377 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""The credential-gated distributor catalogues. + +These sources yield part IDENTITY (MPN, datasheet, stock) and never +geometry, so most of what is worth guarding is about how they FAIL. An +unconfigured or rejected catalogue that returns an empty list tells the +caller the part does not exist, which is both wrong and unrecoverable: +they stop looking. + +The sharpest case here is measured rather than imagined. Mouser answers +an invalid API key with HTTP **200** and an ``Errors`` array, confirmed +against the live endpoint. Any client that judged success by status code +alone would report a rejected credential as a successful search that +matched nothing. +""" + +from __future__ import annotations + +import io +import json +import urllib.error + +import pytest + +from eda_agent.libimport.providers._distributor import ( + DistributorProvider, + first_string, +) +from eda_agent.libimport.providers.base import ( + ProviderError, + ProviderUnavailable, +) +from eda_agent.libimport.providers.distributors import ( + DigiKeyProvider, + Element14Provider, + MouserProvider, + NexarProvider, + TmeProvider, +) + +ALL_DISTRIBUTORS = (DigiKeyProvider, Element14Provider, MouserProvider, + NexarProvider, TmeProvider) + + +@pytest.fixture(autouse=True) +def _no_ambient_credentials(monkeypatch): + """A developer's real keys must not change what these tests prove.""" + for cls in ALL_DISTRIBUTORS: + for var in cls.env_vars: + monkeypatch.delenv(var, raising=False) + + +def _respond(monkeypatch, payload, status=200): + """Stub the transport with one JSON reply.""" + body = json.dumps(payload).encode("utf-8") + + class _Response(io.BytesIO): + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + monkeypatch.setattr( + "eda_agent.libimport.providers._distributor.urllib.request.urlopen", + lambda *a, **k: _Response(body)) + + +def _raise(monkeypatch, exc): + def _boom(*a, **k): + raise exc + monkeypatch.setattr( + "eda_agent.libimport.providers._distributor.urllib.request.urlopen", + _boom) + + +# ---- refusing to run unconfigured ------------------------------------ + +@pytest.mark.parametrize("cls", ALL_DISTRIBUTORS) +def test_unconfigured_is_unavailable_not_empty(cls): + """"No key" must never look like "no such part". + + An empty list here would end the search: the caller has no way to + tell a silent source from an exhaustive one. + """ + with pytest.raises(ProviderUnavailable) as excinfo: + cls().search("STM32F103") + message = str(excinfo.value) + for var in cls.env_vars: + assert var in message, ( + f"{cls.name} must name {var}; 'not configured' is not " + f"something the user can act on") + + +@pytest.mark.parametrize("cls", ALL_DISTRIBUTORS) +def test_no_credential_ships_with_the_project(cls): + """No default key, for the same reason no default registry ships. + + A bundled credential would make this project the operator's client + and put every user's traffic through one account. + """ + provider = cls() + with pytest.raises(ProviderUnavailable): + provider._credentials() + + +# ---- the measured trap ------------------------------------------------ + +def test_an_error_under_http_200_is_not_an_empty_result(monkeypatch): + """Measured live: Mouser answers a bad key with 200 + Errors. + + This is the single most important assertion in the file. Judged on + status alone the search "succeeded" and found nothing. + """ + monkeypatch.setenv("MOUSER_API_KEY", "wrong-key") + _respond(monkeypatch, { + "Errors": [{"Id": 0, "Code": "Invalid", + "Message": "Invalid unique identifier."}], + "SearchResults": None, + }) + + with pytest.raises(ProviderUnavailable) as excinfo: + MouserProvider().search("STM32F103") + + assert "NOT an empty result" in str(excinfo.value) + + +def test_an_empty_errors_key_is_still_a_success(monkeypatch): + """These APIs send the key unconditionally; empty means fine. + + Without this the guard above would reject every successful search, + which is the failure mode that gets a safety check deleted. + """ + monkeypatch.setenv("MOUSER_API_KEY", "good-key") + _respond(monkeypatch, { + "Errors": [], + "SearchResults": {"Parts": [ + {"ManufacturerPartNumber": "STM32F103C8T6", + "Manufacturer": "STMicroelectronics", + "Description": "ARM MCU", + "DataSheetUrl": "https://example.invalid/ds.pdf"}, + ]}, + }) + + hits = MouserProvider().search("STM32F103") + assert [h.mpn for h in hits] == ["STM32F103C8T6"] + assert hits[0].datasheet.endswith("ds.pdf") + + +# ---- failure classification ------------------------------------------ + +def test_a_rejected_key_is_unavailable_not_an_error(monkeypatch): + """401 means "fix your key", which is a different act from "retry".""" + monkeypatch.setenv("MOUSER_API_KEY", "expired") + _raise(monkeypatch, urllib.error.HTTPError( + "https://api.mouser.com/x", 401, "Unauthorized", {}, None)) + + with pytest.raises(ProviderUnavailable) as excinfo: + MouserProvider().search("anything") + assert "MOUSER_API_KEY" in str(excinfo.value) + + +def test_rate_limiting_says_the_part_may_still_exist(monkeypatch): + monkeypatch.setenv("MOUSER_API_KEY", "k") + _raise(monkeypatch, urllib.error.HTTPError( + "https://api.mouser.com/x", 429, "Too Many", {}, None)) + + with pytest.raises(ProviderUnavailable) as excinfo: + MouserProvider().search("anything") + assert "may still exist" in str(excinfo.value) + + +def test_a_network_failure_is_unavailable_not_empty(monkeypatch): + monkeypatch.setenv("MOUSER_API_KEY", "k") + _raise(monkeypatch, urllib.error.URLError("no route to host")) + + with pytest.raises(ProviderUnavailable) as excinfo: + MouserProvider().search("anything") + assert "not evidence" in str(excinfo.value) + + +def test_a_server_error_is_an_error_not_a_credential_problem(monkeypatch): + """500 must not send the user off to re-check a key that is fine.""" + monkeypatch.setenv("MOUSER_API_KEY", "k") + _raise(monkeypatch, urllib.error.HTTPError( + "https://api.mouser.com/x", 500, "Server Error", {}, None)) + + with pytest.raises(ProviderError) as excinfo: + MouserProvider().search("anything") + assert not isinstance(excinfo.value, ProviderUnavailable) + + +# ---- credentials must not leak --------------------------------------- + +def test_a_key_in_the_query_string_never_reaches_an_error_message( + monkeypatch): + """Mouser puts the key in the URL, so errors must not echo the URL. + + Not hypothetical: these messages are returned to the caller and end + up in logs and transcripts. The provider does not choose where the + key goes, but it does choose what it repeats back. + """ + secret = "SUPERSECRETKEY123" + monkeypatch.setenv("MOUSER_API_KEY", secret) + _raise(monkeypatch, urllib.error.HTTPError( + f"https://api.mouser.com/api/v1/search/keyword?apiKey={secret}", + 500, "Server Error", {}, None)) + + with pytest.raises(ProviderError) as excinfo: + MouserProvider().search("anything") + assert secret not in str(excinfo.value), ( + "the API key leaked into an error message") + + +def test_a_transport_failure_does_not_echo_the_credential(monkeypatch): + """URLError stringifies whatever it was given; check the real path.""" + secret = "ANOTHERSECRET456" + monkeypatch.setenv("MOUSER_API_KEY", secret) + _raise(monkeypatch, urllib.error.URLError("connection refused")) + + with pytest.raises(ProviderUnavailable) as excinfo: + MouserProvider().search("anything") + assert secret not in str(excinfo.value) + + +# ---- what a catalogue is ---------------------------------------------- + +@pytest.mark.parametrize("cls", ALL_DISTRIBUTORS) +def test_a_catalogue_yields_no_geometry(cls): + """The distinction that stops a caller choosing an unbuildable part.""" + provider = cls() + assert provider.kind == "catalogue" + assert provider.formats == () + assert provider.native_to == () + + +@pytest.mark.parametrize("cls", ALL_DISTRIBUTORS) +def test_endpoints_are_https_and_not_placeholders(cls): + """Every URL here was probed live before being written down.""" + provider = cls() + urls = [v for k, v in vars(cls).items() + if k.endswith("_URL") and isinstance(v, str)] + assert urls, f"{provider.name} declares no endpoint" + for url in urls: + assert url.startswith("https://"), f"{url} is not https" + assert "example" not in url and "TODO" not in url + + +@pytest.mark.parametrize("cls", ALL_DISTRIBUTORS) +def test_the_unverified_claim_is_published_not_hidden(cls): + """The endpoint was measured; the response shape was not. + + Saying so is the same discipline the tool catalog already applies to + maturity: a claim nobody checked is worth less than an honest blank. + """ + assert cls.verified_live is False, ( + "flip this only when the client has actually run against the " + "live API with a real credential") + + +def test_a_catalogue_hit_states_it_carries_no_symbol(monkeypatch): + monkeypatch.setenv("MOUSER_API_KEY", "k") + _respond(monkeypatch, {"SearchResults": {"Parts": [ + {"ManufacturerPartNumber": "NE555P", "Manufacturer": "TI"}]}}) + + hit = MouserProvider().search("NE555")[0] + assert "no symbol or footprint" in hit.provenance + + +# ---- shape drift ------------------------------------------------------ + +def test_a_renamed_field_degrades_a_hit_rather_than_losing_the_search(): + """Distributor payloads rename fields between API versions. + + Losing the whole result set because one key moved would turn a + cosmetic upstream change into an outage. + """ + assert first_string({"a": {"b": "x"}}, ("a", "b")) == "x" + assert first_string({"old": "v"}, ("new",), ("old",)) == "v" + assert first_string({"a": None}, ("a",)) == "" + # A non-dict midway must not raise. + assert first_string({"a": "scalar"}, ("a", "b")) == "" + + +def test_a_result_missing_its_mpn_is_skipped_not_faked(monkeypatch): + """A hit with no part number is not addressable, so it is dropped.""" + monkeypatch.setenv("MOUSER_API_KEY", "k") + _respond(monkeypatch, {"SearchResults": {"Parts": [ + {"Manufacturer": "TI", "Description": "mystery"}, + {"ManufacturerPartNumber": "NE555P", "Manufacturer": "TI"}, + ]}}) + + hits = MouserProvider().search("x") + assert [h.mpn for h in hits] == ["NE555P"] + + +def test_a_non_json_body_is_reported_as_such(monkeypatch): + monkeypatch.setenv("MOUSER_API_KEY", "k") + + class _Response(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + monkeypatch.setattr( + "eda_agent.libimport.providers._distributor.urllib.request.urlopen", + lambda *a, **k: _Response(b"maintenance")) + + with pytest.raises(ProviderError) as excinfo: + MouserProvider().search("x") + assert "not JSON" in str(excinfo.value) + + +# ---- the signed one --------------------------------------------------- + +def test_tme_signature_is_deterministic_and_key_dependent(): + """A signature that ignored the secret would authenticate nothing.""" + provider = TmeProvider() + params = {"Token": "t", "SearchPlain": "NE555"} + one = provider._sign(TmeProvider._SEARCH_URL, params, "secret-a") + two = provider._sign(TmeProvider._SEARCH_URL, params, "secret-a") + three = provider._sign(TmeProvider._SEARCH_URL, params, "secret-b") + + assert one == two, "signing must be deterministic" + assert one != three, "the signature must depend on the secret" + assert one != provider._sign( + TmeProvider._SEARCH_URL, {"Token": "t", "SearchPlain": "LM358"}, + "secret-a"), "the signature must depend on the parameters" + + +def test_tme_signature_does_not_depend_on_parameter_order(): + """TME sorts parameters before signing; a dict order must not leak.""" + provider = TmeProvider() + a = provider._sign("https://api.tme.eu/x", + {"A": "1", "B": "2"}, "s") + b = provider._sign("https://api.tme.eu/x", + {"B": "2", "A": "1"}, "s") + assert a == b + + +# ---- fetch ------------------------------------------------------------ + +def test_fetch_says_plainly_that_there_is_nothing_to_place(monkeypatch): + monkeypatch.setenv("MOUSER_API_KEY", "k") + _respond(monkeypatch, {"SearchResults": {"Parts": [ + {"ManufacturerPartNumber": "NE555P", "Manufacturer": "TI", + "DataSheetUrl": "https://example.invalid/ne555.pdf"}]}}) + + detail = MouserProvider().fetch("NE555P") + assert detail["kind"] == "catalogue" + assert detail["files"] == {} + assert "NOT a symbol or footprint" in detail["note"] + + +def test_fetch_of_an_absent_part_is_an_error_not_a_blank(monkeypatch): + monkeypatch.setenv("MOUSER_API_KEY", "k") + _respond(monkeypatch, {"SearchResults": {"Parts": []}}) + + with pytest.raises(ProviderError): + MouserProvider().fetch("NOSUCHPART") + + +# ---- the base class contract ----------------------------------------- + +def test_the_base_refuses_to_be_used_directly(): + """A subclass that forgets search must fail loudly, not silently.""" + class Incomplete(DistributorProvider): + name = "incomplete" + env_vars = () + + with pytest.raises(NotImplementedError): + Incomplete().search("x") diff --git a/tests/test_part_providers.py b/tests/test_part_providers.py new file mode 100644 index 0000000..3c2f2e9 --- /dev/null +++ b/tests/test_part_providers.py @@ -0,0 +1,1012 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Part providers: many sources, none privileged. + +The requirement is that no provider is a default and all are searched +equally. That is easy to state and easy to erode: a fallback order, a +relevance sort, or a "primary" setting each quietly turns one source +into the answer for every query and hands its operator the tool surface. + +So these tests assert the neutrality directly rather than trusting the +docstrings: every enabled provider is queried, the merged order is +alphabetical rather than ranked, one source cannot crowd out another, +and a provider that cannot answer says why instead of returning an +empty list that would read as "no such part". + +Network is stubbed throughout (the session-wide guard in conftest +blocks urllib anyway), so none of this depends on a third party being +reachable. +""" + +from __future__ import annotations + +import pytest + +from eda_agent.libimport.providers import ( + PartHit, + ProviderError, + ProviderUnavailable, + available_providers, + get_provider, + search_all, +) + + +class _Fake: + """A provider whose behaviour each test dictates.""" + + def __init__(self, name, hits=(), raises=None, description="fake"): + self.name = name + self.description = description + self._hits = list(hits) + self._raises = raises + self.searched = 0 + + def search(self, query, limit=20): + self.searched += 1 + if self._raises is not None: + raise self._raises + return [PartHit(provider=self.name, part_id=h, mpn=h) + for h in self._hits][:limit] + + def fetch(self, part_id): + return {"provider": self.name, "part_id": part_id} + + +@pytest.fixture(autouse=True) +def _configured_registry(monkeypatch): + """Point the registry client somewhere for tests that exercise it. + + The provider ships with NO default URL on purpose: a default would + aim every install at one company's service. So these tests have to + configure it, exactly as a real user must. The URL is a stub host + and the HTTP layer is faked, so nothing leaves the machine. + """ + monkeypatch.setenv("PARTS_REGISTRY_URL", "https://registry.invalid") + + +@pytest.fixture(autouse=True) +def _reset_partreel_memo(): + """Clear the module-level index memo around every test. + + PartReel caches its parsed index at module scope (a fresh provider is + built per available_providers() call, so instance state would never + be reused). That is right for the server and wrong for tests: one + test's stub index would otherwise answer another test's search, and + the failure would depend on file order. + """ + from eda_agent.libimport.providers import partreel + + partreel._MEMO, partreel._MEMO_AT = None, 0.0 + yield + partreel._MEMO, partreel._MEMO_AT = None, 0.0 + + +@pytest.fixture +def only_fakes(monkeypatch): + """Replace the real registry so tests never touch a network.""" + def install(*providers): + monkeypatch.setattr( + "eda_agent.libimport.providers.available_providers", + lambda: sorted(providers, key=lambda p: p.name)) + return providers + return install + + +# ---------------------- neutrality ---------------------------------- + +def test_every_provider_is_queried(only_fakes): + """Not "first one that answers": all of them, every time.""" + a, b, c = only_fakes(_Fake("aaa", ["1"]), _Fake("mmm", ["2"]), + _Fake("zzz", ["3"])) + result = search_all("x") + assert (a.searched, b.searched, c.searched) == (1, 1, 1) + assert result["count"] == 3 + + +def test_a_rich_provider_cannot_crowd_out_the_others(only_fakes): + """The cap is per provider, so one big registry cannot dominate.""" + only_fakes(_Fake("aaa", [str(i) for i in range(100)]), + _Fake("zzz", ["only-one"])) + result = search_all("x", limit_per_provider=5) + by_provider = {} + for hit in result["hits"]: + by_provider.setdefault(hit["provider"], []).append(hit) + assert len(by_provider["aaa"]) == 5 + assert len(by_provider["zzz"]) == 1 + + +def test_merged_order_is_alphabetical_not_ranked(only_fakes): + """Ordering must carry no quality judgement. + + Sorting by anything else (hit count, response time, a preference + list) would make one source systematically appear first, which is + the same thing as having a default. + """ + only_fakes(_Fake("zzz", ["z1"]), _Fake("aaa", ["a1"])) + hits = search_all("x")["hits"] + assert [h["provider"] for h in hits] == ["aaa", "zzz"] + + +def test_there_is_no_default_provider_setting(): + """A fetch must name its source; nothing supplies one implicitly.""" + import inspect + + from eda_agent.tools.parts import register_parts_tools + + captured = {} + + class _Capture: + def tool(self, *a, **k): + def deco(fn): + captured[fn.__name__] = fn + return fn + return deco + + register_parts_tools(_Capture()) + sig = inspect.signature(captured["part_fetch"]) + provider = sig.parameters["provider"] + + # The rule is that no SOURCE is ever preferred, which is a property + # of behaviour rather than of the signature. `provider` carries an + # empty default so a caller can pass the self-describing `ref` a + # search returned instead of splitting it by hand; empty selects + # nothing. Asserting "no default" instead would forbid the ref form + # while permitting, say, provider="partreel" as the default, which + # is the thing actually worth preventing. + assert provider.default in (inspect.Parameter.empty, ""), ( + f"part_fetch defaults provider to {provider.default!r}; that is " + "a preferred source by another name") + + # The property itself: an id that names no source is REFUSED, not + # resolved against some provider the code picked. + import asyncio + refused = asyncio.run(captured["part_fetch"](part_id="LM317")) + assert refused["ok"] is False + assert "provider" not in refused, ( + "a refusal must not attribute the request to any source") + assert "part_search" in refused["reason"], ( + "the refusal must point at where a valid ref comes from") + + +# ---------------------- failure honesty ------------------------------ + +def test_unavailable_provider_does_not_suppress_the_others(only_fakes): + dead = _Fake("dead", raises=ProviderUnavailable("endpoint withdrawn")) + live = _Fake("live", ["ok1", "ok2"]) + only_fakes(dead, live) + result = search_all("x") + assert result["count"] == 2 + assert result["providers"]["dead"]["ok"] is False + assert "withdrawn" in result["providers"]["dead"]["unavailable"] + assert result["providers"]["live"]["ok"] is True + + +def test_unavailable_is_not_reported_as_no_results(only_fakes): + """The distinction the whole fan-out depends on. + + An empty list from a dead endpoint would read as "this source has no + such part", which it is in no position to claim. + """ + only_fakes(_Fake("dead", raises=ProviderUnavailable("gone"))) + result = search_all("x") + assert result["count"] == 0 + assert result["providers"]["dead"]["ok"] is False + assert "unavailable" in result["providers"]["dead"] + + +def test_an_unexpected_exception_in_one_provider_is_contained(only_fakes): + """A buggy provider must not take the search down with it.""" + only_fakes(_Fake("boom", raises=RuntimeError("kaboom")), + _Fake("fine", ["a"])) + result = search_all("x") + assert result["count"] == 1 + assert "kaboom" in result["providers"]["boom"]["error"] + + +def test_no_reachable_provider_is_stated_explicitly(): + """"Nothing found" and "nothing could answer" must not look alike.""" + import asyncio + + from eda_agent.tools.parts import register_parts_tools + + captured = {} + + class _Capture: + def tool(self, *a, **k): + def deco(fn): + captured[fn.__name__] = fn + return fn + return deco + + register_parts_tools(_Capture()) + + import eda_agent.libimport.providers as prov + original = prov.available_providers + prov.available_providers = lambda: [ + _Fake("dead", raises=ProviderUnavailable("gone"))] + try: + out = asyncio.run(captured["part_search"](query="anything")) + finally: + prov.available_providers = original + assert out["count"] == 0 + assert "NO provider could answer" in out["note"] + + +# ---------------------- real providers ------------------------------- + +def test_easyeda_search_refuses_rather_than_returning_empty(): + """Verified against the live service: the endpoint is withdrawn.""" + from eda_agent.libimport.providers.easyeda import EasyEdaProvider + + with pytest.raises(ProviderUnavailable) as excinfo: + EasyEdaProvider().search("anything") + assert "search" in str(excinfo.value).lower() + + +def test_partreel_filters_the_index_locally(monkeypatch): + """PartReel has NO server-side search: ?q= is silently ignored. + + Search therefore means pulling the whole index (11.4 MB, 21,657 + parts on the live service) and filtering here, which is why the + index is cached. + """ + from eda_agent.libimport.providers import partreel + + index = [ + {"id": "a_stm32", "name": "STM32F103C8T6", "manufacturer": "ST", + "family": "MCU", "keywords": ["arm"]}, + {"id": "b_res", "name": "R0603", "manufacturer": "Yageo", + "family": "resistor", "keywords": []}, + ] + monkeypatch.setattr(partreel, "get_json_cached", + lambda url, hosts, ttl: index) + hits = partreel.PartReelProvider().search("stm32") + assert [h.part_id for h in hits] == ["a_stm32"] + assert hits[0].provider == "partreel" + + +def test_partreel_rejects_a_traversal_in_the_part_id(): + """Part ids are registry data, so they are untrusted input.""" + from eda_agent.libimport.providers.partreel import PartReelProvider + + for bad in ("../../etc/passwd", "a/b"): + with pytest.raises(ProviderError): + PartReelProvider().fetch(bad) + + +def test_kicad_local_skips_per_unit_sub_symbols(monkeypatch, tmp_path): + """``NAME_0_1`` / ``NAME_1_1`` are unit bodies, not parts. + + Counting them would triple the hit count with entries that cannot be + placed. + """ + from eda_agent.libimport.providers import kicad_local + + lib = tmp_path / "MCU_Test.kicad_sym" + lib.write_text( + '(kicad_symbol_lib\n' + ' (symbol "REALPART" (in_bom yes)\n' + ' (symbol "REALPART_0_1")\n' + ' (symbol "REALPART_1_1")\n' + ' )\n)\n', encoding="utf-8") + monkeypatch.setattr(kicad_local, "_symbol_dir", lambda: tmp_path) + + hits = kicad_local.KicadLocalProvider().search("realpart") + assert [h.mpn for h in hits] == ["REALPART"] + + +def test_kicad_local_reports_unavailable_when_not_installed(monkeypatch): + from eda_agent.libimport.providers import kicad_local + + monkeypatch.setattr(kicad_local, "_symbol_dir", lambda: None) + with pytest.raises(ProviderUnavailable): + kicad_local.KicadLocalProvider().search("anything") + + +# ---------------------- selection, not ranking ----------------------- + +def test_env_var_selects_a_subset_without_ranking(monkeypatch): + monkeypatch.setenv("EDA_AGENT_PART_PROVIDERS", "partreel") + names = [p.name for p in available_providers()] + assert names == ["partreel"] + + monkeypatch.delenv("EDA_AGENT_PART_PROVIDERS") + names = [p.name for p in available_providers()] + assert names == sorted(names), "registry order must stay alphabetical" + assert len(names) >= 3 + + +def test_unknown_provider_names_the_enabled_ones(): + with pytest.raises(ProviderError) as excinfo: + get_provider("no-such-registry") + assert "enabled:" in str(excinfo.value) + + +def test_partreel_index_is_memoised_across_instances(monkeypatch): + """available_providers() builds a fresh provider per call. + + Per-instance caching would therefore never be reused, and every + search would re-parse the whole index (11.8 MB on the live service, + ~0.2s each). The memo has to be module level. + """ + from eda_agent.libimport.providers import partreel + + calls = {"n": 0} + + def fake(url, hosts, ttl): + calls["n"] += 1 + return [{"id": "x", "name": "PART-X", "manufacturer": "M", + "family": "f", "keywords": []}] + + monkeypatch.setattr(partreel, "get_json_cached", fake) + assert partreel.PartReelProvider().search("part-x") + assert partreel.PartReelProvider().search("part-x") + assert partreel.PartReelProvider().search("part-x") + assert calls["n"] == 1, ( + f"index fetched {calls['n']} times across 3 provider instances; " + f"the memo is not shared") + + + +def test_search_still_works_with_no_network(monkeypatch, tmp_path): + """A dead internet must degrade the search, not break it. + + This is the reason a LOCAL provider is in the registry at all: both + remote sources can be withdrawn (EasyEDA's search already was) or + put behind a login, and KiCad's installed libraries cannot. + """ + import urllib.request + + from eda_agent.libimport.providers import search_all + + monkeypatch.setenv("EDA_AGENT_CACHE_DIR", str(tmp_path)) + + def deny(*args, **kwargs): + raise OSError("network is unreachable") + + monkeypatch.setattr(urllib.request, "urlopen", deny) + monkeypatch.setattr(urllib.request.OpenerDirector, "open", deny) + + result = search_all("STM32F103", limit_per_provider=3) + + # The local provider still answers. + assert result["providers"]["kicad_local"]["ok"] is True + assert result["count"] >= 1 + # And the remote ones explain themselves rather than going quiet. + assert result["providers"]["partreel"]["ok"] is False + assert "unavailable" in result["providers"]["partreel"] or \ + "error" in result["providers"]["partreel"] + + +def test_correlation_groups_across_providers(only_fakes): + """Answers "who has this part", the question a fan-out raises.""" + a = _Fake("aaa"); b = _Fake("zzz") + a.search = lambda q, limit=20: [ + PartHit(provider="aaa", part_id="a1", mpn="LM358")] + b.search = lambda q, limit=20: [ + PartHit(provider="zzz", part_id="z1", mpn="lm-358")] + only_fakes(a, b) + + groups = search_all("lm358")["by_mpn"] + assert len(groups) == 1, "cosmetic spelling differences must fold" + assert groups[0]["provider_count"] == 2 + # Providers listed alphabetically inside the group, for the same + # reason the hit list is: any other order reads as a recommendation. + assert [p["provider"] for p in groups[0]["providers"]] == ["aaa", "zzz"] + + +def test_correlation_does_not_fold_wildcard_part_numbers(only_fakes): + """``...C8Tx`` is a FAMILY placeholder, not a spelling of ``...C8T6``. + + KiCad uses the x suffix to cover several variants with different + packages and temperature grades. Merging them would assert an + equivalence this code cannot support. + """ + a = _Fake("aaa"); b = _Fake("zzz") + a.search = lambda q, limit=20: [ + PartHit(provider="aaa", part_id="a1", mpn="STM32F103C8T6")] + b.search = lambda q, limit=20: [ + PartHit(provider="zzz", part_id="z1", mpn="STM32F103C8Tx")] + only_fakes(a, b) + + groups = search_all("stm32")["by_mpn"] + assert len(groups) == 2, ( + "wildcard and specific part numbers were merged; that claims an " + "equivalence between a family placeholder and one variant") + + +# ---------------------- downloading artefacts ------------------------ + +def test_download_only_takes_allowlisted_kinds(monkeypatch, tmp_path): + """File URLs are registry data, so the extension is chosen HERE. + + Writing whatever extension the payload names is how a download turns + into an executable. Only the known artefact kinds are taken, each + with the suffix this code expects. + """ + from eda_agent.libimport.providers import partreel + + monkeypatch.setattr(partreel.PartReelProvider, "fetch", + lambda self, pid: { + "id": "part_x", + "files": { + "footprint": "https://partreel.com/a.kicad_mod", + "symbol": "https://partreel.com/a.kicad_sym", + "installer": "https://partreel.com/evil.exe", + "preview": "https://partreel.com/p.png", + }}) + monkeypatch.setattr(partreel, "get_bytes", + lambda url, hosts: b"(footprint (version 20251024)") + + written = partreel.PartReelProvider().download("part_x", tmp_path) + names = sorted(p.name for p in tmp_path.iterdir()) + assert names == ["part_x.kicad_mod", "part_x.kicad_sym"], names + assert "installer" not in written and "preview" not in written + + +def test_download_warns_when_the_format_is_newer_than_local_kicad( + monkeypatch, tmp_path): + """Observed live: the registry ships a newer format than KiCad reads. + + Format 20260206 against KiCad 10.0.1's 20251024, where the symbol + parser refuses the file outright. A file that will not open is not a + successful download. + """ + from eda_agent.libimport.providers import partreel + + monkeypatch.setattr(partreel.PartReelProvider, "fetch", + lambda self, pid: { + "id": "p", "files": { + "symbol": "https://partreel.com/a.kicad_sym"}}) + monkeypatch.setattr(partreel, "get_bytes", + lambda url, hosts: + b"(kicad_symbol_lib\n\t(version 20260206)\n") + monkeypatch.setattr(partreel, "_local_kicad_version", lambda: 20251024) + + written = partreel.PartReelProvider().download("p", tmp_path) + assert "symbol" in written, "the file should still be written" + assert "20260206" in written["symbol_warning"] + assert "20251024" in written["symbol_warning"] + + +def test_download_is_quiet_when_the_format_is_readable(monkeypatch, + tmp_path): + """No warning when the local KiCad can open it, or is unknown.""" + from eda_agent.libimport.providers import partreel + + monkeypatch.setattr(partreel.PartReelProvider, "fetch", + lambda self, pid: { + "id": "p", "files": { + "symbol": "https://partreel.com/a.kicad_sym"}}) + monkeypatch.setattr(partreel, "get_bytes", + lambda url, hosts: + b"(kicad_symbol_lib\n\t(version 20240101)\n") + monkeypatch.setattr(partreel, "_local_kicad_version", lambda: 20251024) + + written = partreel.PartReelProvider().download("p", tmp_path) + assert "symbol_warning" not in written + + +def test_one_failed_artefact_does_not_lose_the_others(monkeypatch, + tmp_path): + from eda_agent.libimport.providers import partreel + from eda_agent.libimport.providers._http import FetchError + + monkeypatch.setattr(partreel.PartReelProvider, "fetch", + lambda self, pid: { + "id": "p", "files": { + "symbol": "https://partreel.com/a.kicad_sym", + "footprint": "https://partreel.com/a.kicad_mod", + }}) + + def flaky(url, hosts): + if url.endswith(".kicad_sym"): + raise FetchError("500 from registry") + return b"(footprint (version 20251024)" + + monkeypatch.setattr(partreel, "get_bytes", flaky) + written = partreel.PartReelProvider().download("p", tmp_path) + assert "footprint" in written + assert "500" in written["symbol_error"] + + +def test_provider_without_download_says_so_rather_than_failing(): + """kicad_local locates a symbol already on disk; nothing to fetch.""" + import asyncio + + from eda_agent.tools.parts import register_parts_tools + + captured = {} + + class _Capture: + def tool(self, *a, **k): + def deco(fn): + captured[fn.__name__] = fn + return fn + return deco + + register_parts_tools(_Capture()) + out = asyncio.run(captured["part_fetch"]( + provider="kicad_local", + part_id="MCU_ST_STM32F1:STM32F103C8Tx", + download_dir="unused")) + assert out["ok"] is True + assert out["files"] == {} + assert "no downloadable files" in out["download_note"] + + +def test_part_fetch_is_not_advertised_as_readonly(): + """It writes library files when given download_dir. + + The "parts" category is offline, and offline falls back to READONLY, + which would advertise a filesystem-touching tool as read-only. Every + other file-writing tool in this server (lib_easyeda_import, + lib_extract_cse_zip, proj_export_pdf, pcb_render_svg) is classified + silent, so this matches them rather than inventing a third answer. + """ + from eda_agent.tools.metadata import tool_metadata + + assert tool_metadata("part_fetch")["interaction"] == "silent" + # part_search never writes, so it stays readonly. + assert tool_metadata("part_search")["interaction"] == "readonly" + + +def test_every_provider_declares_what_it_yields(): + """Whether a hit is USABLE depends on the format it comes in. + + This server converts EasyEDA payloads to Altium but has NO + KiCad->Altium path, so a kicad-only provider is a dead end for an + Altium user. Making that visible before they spend time on a hit is + the difference between a limitation and a trap. + """ + for provider in available_providers(): + formats = getattr(provider, "formats", None) + usable = getattr(provider, "usable_in", None) + native = getattr(provider, "native_to", ()) + kind = getattr(provider, "kind", "library") + assert kind in ("library", "catalogue"), ( + f"{provider.name} declares an unknown kind {kind!r}") + if kind == "catalogue": + # A catalogue yields identity and a datasheet, never + # geometry. Declaring a format here would be a category + # error, and would put the hit in front of an importer that + # has nothing to import. + assert not formats and not native, ( + f"{provider.name} is a catalogue yet declares formats or " + f"native_to; a source that ships files is a library") + else: + assert formats or native, ( + f"{provider.name} declares neither formats nor native_to; " + "a source must say what it yields, or that its parts are " + "already in a backend's own format") + assert usable, f"{provider.name} declares no usable_in" + for backend in usable: + assert backend in ("altium", "kicad"), ( + f"{provider.name} claims an unknown backend {backend!r}") + + +def test_a_client_points_at_its_own_service_and_stays_overridable( + monkeypatch): + """A default endpoint is not a preference. + + Every client here points at the service it is a client of: the + Digi-Key client at Digi-Key, the registry client at the registry it + was written against. What must not exist is RANKING, which the + ordering and fan-out tests cover separately. + + The override matters just as much: the API shape is the contract, + not the host, so an API-compatible registry must be substitutable + without touching code. + """ + from eda_agent.libimport.providers import partreel + + monkeypatch.delenv("PARTS_REGISTRY_URL", raising=False) + assert partreel._base() == partreel._DEFAULT_BASE + assert partreel._DEFAULT_BASE.startswith("https://") + + monkeypatch.setenv("PARTS_REGISTRY_URL", "https://other.invalid/") + assert partreel._base() == "https://other.invalid", ( + "the registry must be substitutable, and a trailing slash must " + "not survive into a joined URL") + + +def test_no_provider_is_consulted_only_when_another_comes_back_thin( + only_fakes): + """A fallback is a ranking wearing a different name. + + The fan-out must query every source unconditionally, so that a + source cannot become "the one that answers when nothing else did", + which is precisely the position a default endpoint could otherwise + create. + """ + from eda_agent.libimport.providers import search_all + + result = search_all("anything", 20) + assert set(result["providers"]) == {p.name for p in + available_providers()}, ( + "every provider must report a status, whether or not the others " + "found anything") + + +def test_the_readme_table_lists_every_provider_with_its_real_kind(): + """The README states the provider set; so does the code. + + A fact stated in two places with nothing enforcing agreement is the + defect shape that keeps surfacing in this project. Here the drift is + especially quiet: a provider added to the registry but missing from + the table is invisible to anyone reading the docs, and a table row + whose `kind` disagrees with the class sends the reader looking for a + symbol that a catalogue never had. + """ + import pathlib + import re + + readme = (pathlib.Path(__file__).resolve().parents[1] + / "README.md").read_text(encoding="utf-8") + + # Only the provider table: rows whose second cell is the kind. + documented = { + match.group(1): match.group(2) + for match in re.finditer( + r"^\|\s*`(\w+)`\s*\|\s*(library|catalogue)\s*\|", + readme, re.MULTILINE) + } + assert documented, "the provider table is missing or reshaped" + + for provider in available_providers(): + assert provider.name in documented, ( + f"{provider.name} is registered but absent from the README " + f"provider table") + kind = getattr(provider, "kind", "library") + assert documented[provider.name] == kind, ( + f"README calls {provider.name} a " + f"{documented[provider.name]}, the code says {kind}") + + extra = set(documented) - {p.name for p in available_providers()} + assert not extra, ( + f"the README documents providers that are not registered: " + f"{sorted(extra)}") + + +def test_the_readme_names_the_env_var_each_catalogue_actually_reads(): + """A credential name is useless if it is the wrong one. + + The variable in the docs is what the user exports; the variable in + the code is what gets read. Nothing else connects them, and a typo + presents as "this provider never works" with no clue why. + """ + import pathlib + import re + + readme = (pathlib.Path(__file__).resolve().parents[1] + / "README.md").read_text(encoding="utf-8") + + for provider in available_providers(): + for var in getattr(provider, "env_vars", ()): + # Word-boundary, not substring. A README that documented + # TME_SECRET_KEY while the code read TME_SECRET would + # satisfy a plain `in` check and still leave the user + # exporting a variable nothing reads. + assert re.search(rf"\b{re.escape(var)}\b", readme), ( + f"{provider.name} reads {var} but the README never " + f"names it exactly, so nobody can enable this source") + + +def test_altium_usability_matches_the_converters_that_exist(): + """A provider may only claim Altium if a conversion path exists. + + The claim has to be tied to a converter that is registered, not to + an intention: a provider advertising Altium usability for a format + nothing reads sends the user down a path that dead-ends. + """ + from eda_agent.libimport.providers.base import ( + ALTIUM_CONVERTIBLE_FORMATS, + ) + + for provider in available_providers(): + if "altium" not in getattr(provider, "usable_in", ()): + continue + if getattr(provider, "kind", "library") == "catalogue": + # Nothing is converted because nothing is yielded: the + # usable_in claim covers the IDENTITY, which is tool-neutral. + # Guarded rather than waved through, so a catalogue cannot + # quietly start advertising files. + assert not getattr(provider, "formats", ()), ( + f"{provider.name} is a catalogue yet yields formats") + continue + if "altium" in getattr(provider, "native_to", ()): + # Already an Altium symbol, so there is no conversion to + # dead-end in. Nativeness must be DECLARED, not inferred + # from an empty formats tuple, or omitting formats would + # become a way around this check. + assert not getattr(provider, "formats", ()), ( + f"{provider.name} claims to be native to Altium yet also " + "yields files to convert; it must be one or the other") + continue + formats = set(getattr(provider, "formats", ())) + convertible = formats & set(ALTIUM_CONVERTIBLE_FORMATS) + assert convertible, ( + f"{provider.name} claims Altium usability but none of its " + f"formats {sorted(formats)} appears in " + f"ALTIUM_CONVERTIBLE_FORMATS; the claim must point at a " + f"converter that exists") + + +def test_every_declared_converter_is_a_registered_tool(): + """The converter map must name real tools, not intentions. + + A renamed or removed importer would otherwise leave a provider + advertising a path that no longer exists. + """ + from eda_agent.libimport.providers.base import ( + ALTIUM_CONVERTIBLE_FORMATS, + ) + from eda_agent.tools import register_backend + from eda_agent.tools.registry import ToolRegistry + + registry = ToolRegistry() + register_backend(registry, "altium", "full") + for fmt, tool in ALTIUM_CONVERTIBLE_FORMATS.items(): + assert tool in registry, ( + f"format {fmt} claims converter {tool}, which is not a " + f"registered tool") + + +def test_a_hit_names_the_tool_that_converts_it(): + """A hit alone cannot say whether it is usable, so search says it. + + The formats live on the provider, not on the hit, so a caller + reading only the result list could not tell that a KiCad-format part + is usable on Altium at all. That is the single fact that decides + whether a hit is a lead or a dead end. + """ + from eda_agent.libimport.providers import _describe + + class _Fake: + name = "fake" + formats = ("kicad_sym", "kicad_mod") + usable_in = ("kicad", "altium") + + hit = PartHit(provider="fake", part_id="X", mpn="X") + out = _describe(hit, _Fake()) + assert out["formats"] == ["kicad_sym", "kicad_mod"] + assert out["usable_in"] == ["kicad", "altium"] + # Both formats map to the same importer; it must not be listed twice. + assert out["import_with"] == ["lib_kicad_import"] + + +def test_a_format_with_no_converter_is_never_advertised(): + """Absence of a converter has to read as absence, not as silence. + + An empty ``import_with`` is the honest answer for a format nothing + reads. Falling back to some default importer would be worse than + saying nothing, because the caller would act on it. + """ + from eda_agent.libimport.providers import _describe + + class _Fake: + name = "fake" + formats = ("some_format_nothing_reads",) + usable_in = ("kicad",) + + out = _describe(PartHit(provider="fake", part_id="X"), _Fake()) + assert out["import_with"] == [] + + +def test_import_with_only_ever_names_registered_tools(): + """Guards the whole surface, not just the one constant. + + ``import_with`` is what an agent acts on directly, so a stale entry + would produce a call to a tool that does not exist. + """ + from eda_agent.tools import register_backend + from eda_agent.tools.registry import ToolRegistry + + registry = ToolRegistry() + register_backend(registry, "altium", "full") + + for provider in available_providers(): + out = _describe_for(provider) + for tool in out: + assert tool in registry, ( + f"provider {provider.name} advertises {tool}, which is " + f"not a registered tool") + + +def _describe_for(provider) -> list: + from eda_agent.libimport.providers import _describe + + hit = PartHit(provider=provider.name, part_id="X") + return _describe(hit, provider)["import_with"] + + +# ------------------ symbol-to-whole-part resolution ------------------ + +@pytest.fixture +def fake_kicad_tree(tmp_path, monkeypatch): + """A miniature KiCad install, so these tests need no real one.""" + symbols = tmp_path / "symbols" + footprints = tmp_path / "footprints" + (footprints / "PKG.pretty").mkdir(parents=True) + symbols.mkdir() + + def symbol(name, footprint_ref): + ref = f'(property "Footprint" "{footprint_ref}" (at 0 0 0))' + return (f' (symbol "{name}"\n' + f' (property "Reference" "U" (at 0 0 0))\n' + f' {ref}\n' + f' (symbol "{name}_1_1"\n' + f' (pin input line (at -5.08 0 0) (length 2.54)\n' + f' (name "A") (number "1")))\n' + f' )') + + (symbols / "LIB.kicad_sym").write_text( + "(kicad_symbol_lib (version 20251024) (generator t)\n" + + symbol("HAS_FP", "PKG:REAL") + "\n" + + symbol("MISSING_FP", "PKG:NOT_INSTALLED") + "\n" + + symbol("NO_FP", "") + "\n" + + symbol("TRAVERSAL", "../outside:SECRET") + "\n)", + encoding="utf-8") + body = ('(footprint "F" (layer "F.Cu")\n' + ' (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu")))') + (footprints / "PKG.pretty" / "REAL.kicad_mod").write_text( + body, encoding="utf-8") + # A real file the traversal reference would reach, so the guard is + # what stops it rather than the target happening not to exist. + outside = tmp_path / "outside.pretty" + outside.mkdir() + (outside / "SECRET.kicad_mod").write_text(body, encoding="utf-8") + + monkeypatch.setenv("EDA_AGENT_KICAD_SYMBOL_DIR", str(symbols)) + monkeypatch.setenv("EDA_AGENT_KICAD_FOOTPRINT_DIR", str(footprints)) + return tmp_path + + +def test_a_symbols_footprint_is_resolved_to_a_real_file(fake_kicad_tree): + """A symbol alone is half a part. + + Converted to Altium without this, the part arrives with no land + pattern and the caller has no way to know one was available. + """ + from eda_agent.libimport.providers.kicad_local import KicadLocalProvider + + out = KicadLocalProvider().fetch("LIB:HAS_FP") + assert out["footprint_ref"] == "PKG:REAL" + assert out["footprint_path"].endswith("REAL.kicad_mod") + assert "Whole part" in out["note"] + + +def test_an_unresolvable_reference_reads_differently_from_none( + fake_kicad_tree): + """"Names a footprint you do not have" and "names none" differ. + + Only the first is worth chasing, so they must not collapse into the + same message. + """ + from eda_agent.libimport.providers.kicad_local import KicadLocalProvider + + provider = KicadLocalProvider() + missing = provider.fetch("LIB:MISSING_FP") + none = provider.fetch("LIB:NO_FP") + + assert missing["footprint_path"] == "" + assert missing["footprint_ref"] == "PKG:NOT_INSTALLED" + assert "not installed" in missing["note"] + + assert none["footprint_path"] == "" + assert none["footprint_ref"] == "" + assert "records no footprint" in none["note"] + assert missing["note"] != none["note"] + + +def test_a_footprint_reference_cannot_escape_the_library_root( + fake_kicad_tree): + """The reference is data read out of a file, so treat it as input. + + The target of the traversal is a file that really exists, so this + fails if the containment check is removed rather than passing + because the path happened to lead nowhere. + """ + from eda_agent.libimport.providers.kicad_local import ( + KicadLocalProvider, + _resolve_footprint, + ) + + escaped = fake_kicad_tree / "outside.pretty" / "SECRET.kicad_mod" + assert escaped.is_file(), "fixture must present a reachable target" + assert _resolve_footprint("../outside:SECRET") is None + assert KicadLocalProvider().fetch("LIB:TRAVERSAL")["footprint_path"] == "" + + +def test_locating_a_symbol_still_works_if_parsing_fails( + fake_kicad_tree, monkeypatch): + """A footprint lookup must not turn a good fetch into a failure.""" + from eda_agent.libimport.providers import kicad_local + + def boom(*a, **k): + raise ValueError("unreadable") + + monkeypatch.setattr( + "eda_agent.libimport.kicad.reader.read_kicad_symbol", boom) + out = kicad_local.KicadLocalProvider().fetch("LIB:HAS_FP") + assert out["symbol"] == "HAS_FP" + assert out["footprint_path"] == "" + + +def test_describe_reports_the_symbols_own_datasheet(fake_kicad_tree): + """The normalised view is what makes sources comparable. + + Without it, part_fetch returns a null summary for this provider and + a caller comparing sources has nothing to compare. + """ + from eda_agent.libimport.providers.kicad_local import KicadLocalProvider + + hit = KicadLocalProvider().describe("LIB:HAS_FP") + assert hit.provider == "kicad_local" + assert hit.package == "REAL" # from the footprint reference + assert hit.license # never silently blank + assert hit.provenance + + +def test_describe_never_invents_an_mpn(fake_kicad_tree): + """A symbol name is not a part number. + + Copying it into the MPN field would put a fabricated part number + into a BOM, which is worse than an admitted blank. + """ + from eda_agent.libimport.providers.kicad_local import KicadLocalProvider + + assert KicadLocalProvider().describe("LIB:HAS_FP").mpn == "" + + +def test_one_part_is_parsed_once_however_many_views_are_asked_for( + fake_kicad_tree, monkeypatch): + """part_fetch calls fetch AND describe for the same part. + + These libraries run to several megabytes, so parsing twice would + double the cost of every call for nothing. + """ + import eda_agent.libimport.kicad.reader as reader + from eda_agent.libimport.providers.kicad_local import KicadLocalProvider + + calls = [] + real = reader.read_kicad_symbol + monkeypatch.setattr( + reader, "read_kicad_symbol", + lambda *a, **k: (calls.append(1), real(*a, **k))[1]) + + provider = KicadLocalProvider() + provider.fetch("LIB:HAS_FP") + provider.describe("LIB:HAS_FP") + assert len(calls) == 1, "the same part was parsed more than once" + + provider.fetch("LIB:NO_FP") + assert len(calls) == 2, "a different part must not reuse the memo" + + +def test_a_multi_unit_part_says_so_before_the_import(tmp_path, monkeypatch): + """Converting one unit and stopping leaves most of the part behind. + + Nothing else in a fetch result would reveal that, so the count has + to arrive with the fetch rather than only as a warning from the + import that follows it. + """ + from eda_agent.libimport.providers.kicad_local import KicadLocalProvider + + symbols = tmp_path / "symbols" + symbols.mkdir() + (symbols / "L.kicad_sym").write_text( + '(kicad_symbol_lib (version 20251024) (generator t)\n' + ' (symbol "DUAL"\n' + ' (property "Reference" "U" (at 0 0 0))\n' + ' (symbol "DUAL_1_1" (pin input line (at -5 0 0) (length 2)\n' + ' (name "A") (number "1")))\n' + ' (symbol "DUAL_2_1" (pin input line (at -5 0 0) (length 2)\n' + ' (name "B") (number "2")))))', + encoding="utf-8") + monkeypatch.setenv("EDA_AGENT_KICAD_SYMBOL_DIR", str(symbols)) + + out = KicadLocalProvider().fetch("L:DUAL") + assert out["unit_count"] == 2 + assert "2 units" in out["units_note"] diff --git a/tests/test_public_libraries_provider.py b/tests/test_public_libraries_provider.py new file mode 100644 index 0000000..0fcf323 --- /dev/null +++ b/tests/test_public_libraries_provider.py @@ -0,0 +1,273 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Openly published KiCad libraries, indexed from GitHub. + +This provider exists so that `partreel` is not the only source that +answers without a credential, so the assertions that matter are about +staying a good citizen of a host that owes us nothing: one request per +repository, a cache that makes repeat searches free, an honest +User-Agent, and a rate limit treated as "back off" rather than "no such +part". + +One rule here is not a style preference but a published restriction. +`gitlab.com/robots.txt` carries `Disallow: /api/v*`, so the GitLab API +is off limits to this client and a test enforces it. KiCad's canonical +symbol repository lives there, which is why this provider serves +footprints and leaves KiCad symbols to `kicad_local`. +""" + +from __future__ import annotations + +import io +import json +import urllib.error + +import pytest + +from eda_agent.libimport.providers import public_libraries as pl +from eda_agent.libimport.providers.base import ( + ProviderError, + ProviderUnavailable, +) + +_TREE = { + "truncated": False, + "tree": [ + {"type": "blob", "path": "Package_SO.pretty/SOIC-8.kicad_mod"}, + {"type": "blob", "path": "Package_SO.pretty/TSSOP-14.kicad_mod"}, + {"type": "blob", "path": "symbols/JLCPCB-Analog.kicad_sym"}, + {"type": "blob", "path": "Archived-Symbols/OLD-PART.kicad_sym"}, + {"type": "blob", "path": "README.md"}, + {"type": "tree", "path": "Package_SO.pretty"}, + ], +} + + +@pytest.fixture(autouse=True) +def _isolated_cache(tmp_path, monkeypatch): + """Never read or write a developer's real cache during tests.""" + monkeypatch.setenv("EDA_AGENT_CACHE_DIR", str(tmp_path)) + + +def _serve(monkeypatch, payload, calls=None): + body = json.dumps(payload).encode("utf-8") + + class _Response(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def _open(request, *a, **k): + if calls is not None: + calls.append(request.full_url if hasattr(request, "full_url") + else str(request)) + return _Response(body) + + monkeypatch.setattr( + "eda_agent.libimport.providers.public_libraries." + "urllib.request.urlopen", _open) + + +def _raise(monkeypatch, exc): + def _boom(*a, **k): + raise exc + monkeypatch.setattr( + "eda_agent.libimport.providers.public_libraries." + "urllib.request.urlopen", _boom) + + +# ---- the published restriction --------------------------------------- + +def test_this_provider_never_touches_the_gitlab_api(): + """gitlab.com/robots.txt says `Disallow: /api/v*`. + + Measured, not assumed. KiCad's canonical symbol repository is hosted + there, which makes this the tempting shortcut precisely because it + is the one place the index would be easiest to build. + """ + import ast + import inspect + + tree = ast.parse(inspect.getsource(pl)) + # Only real string CONSTANTS, never comments or docstrings. The + # module explains this rule in prose that necessarily names the + # forbidden host, and a guard that scanned raw text would match its + # own rationale and pass even after the rule was broken. + docstrings = set() + for node in ast.walk(tree): + if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef)): + doc = ast.get_docstring(node, clean=False) + if doc: + docstrings.add(doc) + + urls = [n.value for n in ast.walk(tree) + if isinstance(n, ast.Constant) and isinstance(n.value, str) + and n.value not in docstrings] + + offenders = [u for u in urls if "gitlab.com/api" in u.replace(" ", "")] + assert not offenders, ( + f"gitlab.com/robots.txt disallows /api/v*, but this module builds " + f"{offenders}; index GitHub instead") + + +def test_the_client_identifies_itself(): + """GitHub requires a User-Agent and blocks requests without one. + + Naming the project rather than impersonating a browser is what lets + the host identify and, if it ever needs to, throttle this traffic. + """ + assert pl._UA and "eda-agent" in pl._UA + assert "Mozilla" not in pl._UA, ( + "impersonating a browser is not how to be a good citizen of an " + "API that is being used by permission") + + +# ---- rate limiting ---------------------------------------------------- + +@pytest.mark.parametrize("code", [403, 429]) +def test_a_rate_limit_is_unavailable_never_an_empty_result(monkeypatch, code): + """GitHub answers an exhausted anonymous budget with 403, not 429. + + Both must mean "back off", because an empty list would tell the + caller the footprint does not exist and end the search. + """ + _raise(monkeypatch, urllib.error.HTTPError( + "https://api.github.com/x", code, "rate limited", {}, None)) + + with pytest.raises(ProviderUnavailable) as excinfo: + pl.PublicLibrariesProvider().search("SOIC") + assert "not evidence" in str(excinfo.value) + + +def test_a_network_failure_is_unavailable_not_empty(monkeypatch): + _raise(monkeypatch, urllib.error.URLError("offline")) + with pytest.raises(ProviderUnavailable): + pl.PublicLibrariesProvider().search("SOIC") + + +# ---- the cache -------------------------------------------------------- + +def test_a_whole_index_costs_one_request_per_repository(monkeypatch): + """Walking directories would cost hundreds and exhaust the budget.""" + calls: list[str] = [] + _serve(monkeypatch, _TREE, calls) + + pl._load_index(refresh=True) + + assert len(calls) == len(pl._SOURCES), ( + f"expected one request per repository, made {len(calls)}") + assert all("recursive=1" in c for c in calls), ( + "a non-recursive listing would need one request per directory") + + +def test_a_second_search_costs_nothing(monkeypatch): + """The cache is what keeps interactive search inside 60 per hour.""" + calls: list[str] = [] + _serve(monkeypatch, _TREE, calls) + + provider = pl.PublicLibrariesProvider() + provider.search("SOIC") + first = len(calls) + provider.search("TSSOP") + + assert len(calls) == first, "the cached index must be reused" + assert first > 0 + + +def test_a_corrupt_cache_rebuilds_instead_of_failing(monkeypatch, tmp_path): + (tmp_path / "eda-agent" / "public-libraries").mkdir(parents=True) + (tmp_path / "eda-agent" / "public-libraries" / "index.json").write_text( + "{not json", encoding="utf-8") + _serve(monkeypatch, _TREE) + + assert pl._load_index(), "a corrupt cache must not be fatal" + + +def test_a_stale_cache_beats_no_answer_when_github_is_down(monkeypatch): + """An outdated land pattern the user can audit beats "no such part".""" + _serve(monkeypatch, _TREE) + pl._load_index(refresh=True) + + _raise(monkeypatch, urllib.error.URLError("offline")) + entries = pl._load_index(refresh=True) + + assert entries, "the stale cache should have been used" + + +# ---- what the index contains ----------------------------------------- + +def test_archived_parts_are_excluded(monkeypatch): + """An archived part looks identical to a current one in a hit. + + Shipping a withdrawn land pattern is exactly what this project + audits for elsewhere, so it is filtered at the index rather than + left to the caller to notice. + """ + _serve(monkeypatch, _TREE) + paths = [e["path"] for e in pl._load_index(refresh=True)] + + assert not any("rchive" in p for p in paths), ( + "archived directories must not reach a result") + assert any("SOIC-8" in p for p in paths) + + +def test_non_library_files_are_ignored(monkeypatch): + _serve(monkeypatch, _TREE) + paths = [e["path"] for e in pl._load_index(refresh=True)] + assert not any(p.endswith(".md") for p in paths) + + +def test_a_truncated_tree_is_recorded_rather_than_looking_small(monkeypatch): + """Silence here would present a partial index as a complete one.""" + _serve(monkeypatch, {"truncated": True, "tree": _TREE["tree"]}) + entries = pl._load_index(refresh=True) + assert any(e.get("truncated") for e in entries) + + +# ---- hits ------------------------------------------------------------- + +def test_a_hit_is_addressable_and_states_its_licence(monkeypatch): + _serve(monkeypatch, _TREE) + hits = pl.PublicLibrariesProvider().search("SOIC-8") + + assert hits + hit = hits[0] + assert "::" in hit.part_id, "hits must be repository-qualified" + assert hit.license, "the declared licence must travel with the hit" + assert hit.provider == "public_libraries" + + +def test_two_repositories_can_hold_the_same_footprint_name(monkeypatch): + """Qualifying by repository is what keeps both addressable.""" + _serve(monkeypatch, _TREE) + hits = pl.PublicLibrariesProvider().search("SOIC-8", limit=50) + assert len({h.part_id for h in hits}) == len(hits), ( + "part_ids must be unique across repositories") + + +def test_fetch_returns_a_raw_url_and_names_the_converter(monkeypatch): + _serve(monkeypatch, _TREE) + provider = pl.PublicLibrariesProvider() + hit = provider.search("SOIC-8")[0] + + detail = provider.fetch(hit.part_id) + assert detail["url"].startswith("https://raw.githubusercontent.com/") + assert detail["format"] == "kicad_mod" + assert "lib_kicad_import" in detail["note"] + + +def test_noassertion_is_not_presented_as_permissive(monkeypatch): + """Blank or unclassified licence terms are not an all-clear.""" + _serve(monkeypatch, _TREE) + provider = pl.PublicLibrariesProvider() + detail = provider.fetch(provider.search("SOIC-8")[0].part_id) + assert "not that it is unrestricted" in detail["note"] + + +def test_a_malformed_part_id_is_refused_clearly(monkeypatch): + _serve(monkeypatch, _TREE) + with pytest.raises(ProviderError) as excinfo: + pl.PublicLibrariesProvider().fetch("no-separator") + assert "::" in str(excinfo.value) From c650b03863dd9058fb158b8aa7f14776fb172248 Mon Sep 17 00:00:00 2001 From: George Saliba Date: Thu, 6 Aug 2026 11:19:58 +0200 Subject: [PATCH 02/16] Report which objects each ERC violation is about Gen_GetErcViolations returned only a description string, so a violation arrived as a category and a sheet name. That is not actionable: the only safe response to "floating input pin, somewhere on this sheet" is to do nothing, because a NoERC marker placed by guesswork silently suppresses a real disconnection and is worse than the warning it clears. IViolation.DM_RelatedObjects carries the offending objects and was being discarded. Each violation now reports them with their kind, their document, and a cross probe string, which is what Altium itself uses to jump to an object and therefore identifies the specific pin or net. related_object_count is reported separately so a violation that exposes no objects stays distinguishable from one whose objects could not be read. Everything read from a related object is declared on IDMObject, the base interface every one of them implements, so no call here can hit the undeclared identifier fault that a narrower interface would risk. Verified on a live project: six object kinds across 27 violations, all with at least one related object. Also in this build: Lib_AddSymbolText places body text in a symbol, so imported text reaches Altium instead of being dropped. Selection is qualified by part id, so a multi part component keeps its parts addressable. PCB_ApplyDnpPasteExclusion removes paste from components marked do not populate. Restoring is refused rather than guessed at, because the applied state is not detectable after the fact. IEEE symbol name conversion moves to Utils with a self test covering the converters, so DelphiScript disagreement with Free Pascal shows up in Altium's own engine rather than at the point of use. --- scripts/altium/Dispatcher.pas | 21 +- scripts/altium/Generic.pas | 237 ++++++++++++++++++- scripts/altium/Library.pas | 430 +++++++++++++++++++++++++++++++++- scripts/altium/Main.pas | 2 +- scripts/altium/PCB.pas | 157 +++++++++++++ scripts/altium/SelfTest.pas | 65 +++++ scripts/altium/StatusForm.pas | 2 +- scripts/altium/Utils.pas | 127 ++++++++++ 8 files changed, 1016 insertions(+), 25 deletions(-) diff --git a/scripts/altium/Dispatcher.pas b/scripts/altium/Dispatcher.pas index 1014205..afe6d2e 100644 --- a/scripts/altium/Dispatcher.pas +++ b/scripts/altium/Dispatcher.pas @@ -76,7 +76,26 @@ // Remove the request file regardless of read outcome so we never reprocess DeleteFile(RequestPath); - If RequestContent = '' Then Exit; + If RequestContent = '' Then + Begin + { ReadFileContent already retried 12 times over ~180ms for a } + { transient sharing violation, so an empty result here means the } + { file was genuinely empty or still locked. Deleting it and } + { exiting SILENTLY left the caller to wait out its entire deadline } + { and report a plain timeout, which reads exactly like a wedged } + { polling loop and sends the user hunting the wrong fault. } + { } + { The id came from the FILENAME via ScanForRequestFile and has not } + { been overwritten by the body's id yet, so the call can still be } + { answered with the actual reason. } + If IsValidRequestId(RequestId) Then + WriteResponseFile(RequestId, + BuildErrorResponse(RequestId, 'REQUEST_UNREADABLE', + 'Request file was empty or unreadable after 12 retries ' + + 'and has been discarded. The polling loop is healthy; ' + + 'retry the call.')); + Exit; + End; // ID arrives in the JSON body. Per-request response files use it for // the filename so concurrent callers each get an isolated response file. diff --git a/scripts/altium/Generic.pas b/scripts/altium/Generic.pas index 6436718..eefbbb1 100644 --- a/scripts/altium/Generic.pas +++ b/scripts/altium/Generic.pas @@ -246,6 +246,11 @@ Else If PropName = 'LibReference' Then Result := Obj.LibReference Else If PropName = 'SourceLibraryName' Then Result := Obj.SourceLibraryName Else If PropName = 'DesignItemId' Then Result := Obj.DesignItemId + // Which part of a multi-part symbol owns this primitive (0 = shared + // across all parts). Without it a caller querying a multi-part + // library symbol cannot tell which part a returned primitive is on. + Else If PropName = 'OwnerPartId' Then Result := IntToStr(Obj.OwnerPartId) + Else If PropName = 'OwnerPartDisplayMode' Then Result := IntToStr(Obj.OwnerPartDisplayMode) Else If PropName = 'ComponentDescription' Then Result := Obj.ComponentDescription Else If PropName = 'UniqueId' Then Result := Obj.UniqueId @@ -1111,7 +1116,10 @@ Else If Copy(Scope, 1, 14) = 'lib_component:' Then Begin { Target a named symbol inside the active SchLib. ScopePath carries } - { the lib-ref name (not a file path). Used by batch-op strings. } + { the lib-ref name (not a file path), optionally suffixed '@N' to } + { select part N of a multi-part symbol. The suffix is left on the } + { string here and split by ApplyLibComponentScope, so ParseScope's } + { signature stays as every other caller expects it. } ScopeType := 'lib_component'; ScopePath := Copy(Scope, 15, Length(Scope)); End @@ -1127,10 +1135,39 @@ { request. Returns False if no such component exists in the active library. } {..............................................................................} Function ApplyLibComponentScope(Var ScopeType : String; ScopePath : String) : Boolean; +Var + AtPos, PartId, I : Integer; + CompName, PartStr : String; Begin Result := True; If ScopeType <> 'lib_component' Then Exit; - If SelectLibComponent(ScopePath) = Nil Then + + { Optional '@N' suffix selects part N of a multi-part symbol. A SchLib } + { iterator only yields the CURRENT part's primitives, so without this } + { every query/modify/delete on a multi-part component could only ever } + { reach part 1 and correcting parts 2..N meant a full rebuild. } + { Scan from the RIGHT: a lib-ref may legitimately contain '@'. } + CompName := ScopePath; + PartId := 1; + AtPos := 0; + For I := Length(ScopePath) DownTo 1 Do + If ScopePath[I] = '@' Then + Begin + AtPos := I; + Break; + End; + If AtPos > 1 Then + Begin + PartStr := Copy(ScopePath, AtPos + 1, Length(ScopePath)); + If (PartStr <> '') And IsIntStr(PartStr) Then + Begin + PartId := StrToIntDef(PartStr, 1); + CompName := Copy(ScopePath, 1, AtPos - 1); + If PartId < 1 Then PartId := 1; + End; + End; + + If SelectLibComponentPart(CompName, PartId) = Nil Then Result := False Else ScopeType := 'active_doc'; @@ -2224,15 +2261,29 @@ { Returns violation count and messages from the DM API. } {..............................................................................} +{ Reports each violation WITH the objects it is about. } +{ } +{ A category and a sheet name are not actionable: "floating input pin" on a } +{ sheet with forty parts does not say which pin, and the only safe response } +{ to that is to do nothing. A NoERC marker placed by guesswork silently } +{ suppresses a real disconnection, which is strictly worse than the warning } +{ it clears. } +{ } +{ IViolation.DM_RelatedObjects carries the offending objects. Everything read } +{ from one is declared on IDMObject, the base interface every related object } +{ implements, so no call here can hit the undeclared-identifier crash that a } +{ narrower interface would risk. DM_PrimaryCrossProbeString is what Altium } +{ itself uses to jump to the object, so it identifies the exact pin or net. } Function Gen_GetErcViolations(Params : String; RequestId : String) : String; Var Workspace : IWorkspace; Project : IProject; Violation : IViolation; - I, VCount, MaxItems : Integer; - JsonItems : String; - First : Boolean; - Desc : String; + RelObj : IDMObject; + I, J, VCount, MaxItems, RelCount : Integer; + JsonItems, RelItems : String; + First, FirstRel : Boolean; + Desc, Detail, Kind, DocName, Probe : String; Begin MaxItems := StrToIntDef(ExtractJsonValue(Params, 'limit'), 100); @@ -2267,10 +2318,69 @@ Desc := '(description unavailable)'; End; + Try + Detail := Violation.DM_DetailString; + Except + Detail := ''; + End; + + { The objects the violation is actually about. Without these the + caller can see that something is wrong but never what, which + is the difference between a report and a to-do list. } + RelItems := ''; + FirstRel := True; + RelCount := 0; + Try + RelCount := Violation.DM_RelatedObjectCount; + Except + RelCount := 0; + End; + + For J := 0 To RelCount - 1 Do + Begin + Try + RelObj := Violation.DM_RelatedObjects(J); + Except + RelObj := Nil; + End; + If RelObj = Nil Then Continue; + + Kind := ''; + DocName := ''; + Probe := ''; + Try + Kind := RelObj.DM_ObjectKindString; + Except + Kind := ''; + End; + Try + DocName := RelObj.DM_OwnerDocumentName; + Except + DocName := ''; + End; + Try + { What Altium uses to cross-probe to this exact object. + This is the field that turns "a floating pin somewhere + on this sheet" into a designator and pin number. } + Probe := RelObj.DM_PrimaryCrossProbeString; + Except + Probe := ''; + End; + + If Not FirstRel Then RelItems := RelItems + ','; + FirstRel := False; + RelItems := RelItems + '{"kind":"' + EscapeJsonString(Kind) + + '","document":"' + EscapeJsonString(DocName) + + '","cross_probe":"' + EscapeJsonString(Probe) + '"}'; + End; + If Not First Then JsonItems := JsonItems + ','; First := False; JsonItems := JsonItems + '{"index":' + IntToStr(I) + - ',"description":"' + EscapeJsonString(Desc) + '"}'; + ',"description":"' + EscapeJsonString(Desc) + + '","detail":"' + EscapeJsonString(Detail) + + '","related_object_count":' + IntToStr(RelCount) + + ',"related_objects":[' + RelItems + ']}'; End; Result := BuildSuccessResponse(RequestId, @@ -3381,6 +3491,83 @@ + '"x2":' + IntToStr(X2) + ',"y2":' + IntToStr(Y2) + '}'); End; +{..............................................................................} +{ InferNetLabelStyle - the sheet's own net-label convention, by majority. } +{ Every net label a tool adds must match the labels already on the target } +{ sheet: FontId (which carries font face AND size in the font table) and } +{ Color. Iterates the existing eNetLabel objects and returns the most common } +{ (FontId, Color) pair. Returns False when the sheet has no net labels yet, } +{ callers then keep their historical defaults so a fresh sheet is unchanged. } +{ Majority, not first-seen: one off-style label from an old edit must not } +{ define the convention. } +{..............................................................................} + +Function InferNetLabelStyle(SchDoc : ISch_Document; + Var OutFontId : Integer; Var OutColor : Integer) : Boolean; +Var + Iterator : ISch_Iterator; + Obj : ISch_GraphicalObject; + Keys, Counts : TStringList; + Key : String; + Idx, I, N, BestN, FId, Col, ColonPos : Integer; +Begin + Result := False; + OutFontId := 0; + OutColor := 0; + If SchDoc = Nil Then Exit; + + Keys := TStringList.Create; + Counts := TStringList.Create; + Try + Iterator := SchDoc.SchIterator_Create; + Try + Iterator.AddFilter_ObjectSet(MkSet(eNetLabel)); + Obj := Iterator.FirstSchObject; + While Obj <> Nil Do + Begin + FId := 0; + Col := 0; + Try FId := Obj.FontId; Except End; + Try Col := Obj.Color; Except End; + If FId > 0 Then + Begin + Key := IntToStr(FId) + ':' + IntToStr(Col); + Idx := Keys.IndexOf(Key); + If Idx < 0 Then + Begin + Keys.Add(Key); + Counts.Add('1'); + End + Else + Counts[Idx] := IntToStr(StrToIntDef(Counts[Idx], 0) + 1); + End; + Obj := Iterator.NextSchObject; + End; + Finally + SchDoc.SchIterator_Destroy(Iterator); + End; + + BestN := 0; + For I := 0 To Keys.Count - 1 Do + Begin + N := StrToIntDef(Counts[I], 0); + If N > BestN Then + Begin + BestN := N; + Key := Keys[I]; + ColonPos := Pos(':', Key); + OutFontId := StrToIntDef(Copy(Key, 1, ColonPos - 1), 0); + OutColor := StrToIntDef( + Copy(Key, ColonPos + 1, Length(Key)), 0); + End; + End; + Result := BestN > 0; + Finally + Keys.Free; + Counts.Free; + End; +End; + {..............................................................................} { Place a net label at coordinates on active schematic } { Params: text, x, y, orientation (0/1/2/3) } @@ -3394,6 +3581,8 @@ NetLabel : ISch_NetLabel; Loc : TLocation; SrvDoc : IServerDocument; + InfFont, InfColor : Integer; + StyleFound : Boolean; Begin Text := ExtractJsonValue(Params, 'text'); SheetPath := ExtractJsonValue(Params, 'sheet_path'); @@ -3442,7 +3631,17 @@ NetLabel.Location := Loc; NetLabel.Text := Text; NetLabel.Orientation := Orientation; - NetLabel.Color := 0; + { Follow the sheet's own net-label convention (font, size via the + font table, colour). Historical default only on a sheet that has + no net labels yet. } + StyleFound := InferNetLabelStyle(SchDoc, InfFont, InfColor); + If StyleFound Then + Begin + Try NetLabel.FontId := InfFont; Except End; + NetLabel.Color := InfColor; + End + Else + NetLabel.Color := 0; SchServer.ProcessControl.PreProcess(SchDoc, ''); SchDoc.RegisterSchObjectInContainer(NetLabel); @@ -6005,6 +6204,8 @@ SchDoc : ISch_Document; NetLabel : ISch_NetLabel; Loc : TLocation; + InfFont, InfColor : Integer; + StyleFound : Boolean; Begin LabelsStr := ExtractJsonValue(Params, 'labels'); If LabelsStr = '' Then @@ -6026,6 +6227,9 @@ OpCount := 0; Remaining := LabelsStr; + { Sheet convention once per batch, applied to every label below. } + StyleFound := InferNetLabelStyle(SchDoc, InfFont, InfColor); + SchServer.ProcessControl.PreProcess(SchDoc, ''); Try While True Do @@ -6063,7 +6267,13 @@ NetLabel.Text := Text; NetLabel.Orientation := Orientation; Try NetLabel.Justification := Justification; Except End; - NetLabel.Color := 0; + If StyleFound Then + Begin + Try NetLabel.FontId := InfFont; Except End; + NetLabel.Color := InfColor; + End + Else + NetLabel.Color := 0; SchDoc.RegisterSchObjectInContainer(NetLabel); SchRegisterObject(SchDoc, NetLabel); @@ -8270,6 +8480,8 @@ Found : Boolean; Wire : ISch_Wire; NetLabel : ISch_NetLabel; + InfFont, InfColor : Integer; + StyleFound : Boolean; Begin SchDoc := SchServer.GetCurrentSchDocument; If SchDoc = Nil Then @@ -8288,6 +8500,8 @@ Stubbed := 0; Failed := 0; + { Sheet convention once, applied to every stub label below. } + StyleFound := InferNetLabelStyle(SchDoc, InfFont, InfColor); SchServer.ProcessControl.PreProcess(SchDoc, ''); Try Remaining := PinsStr; @@ -8376,6 +8590,11 @@ Begin NetLabel.Text := Lbl; NetLabel.Location := Point(MilsToCoord(EX), MilsToCoord(EY)); + If StyleFound Then + Begin + Try NetLabel.FontId := InfFont; Except End; + NetLabel.Color := InfColor; + End; SchDoc.RegisterSchObjectInContainer(NetLabel); SchRegisterObject(SchDoc, NetLabel); End; diff --git a/scripts/altium/Library.pas b/scripts/altium/Library.pas index d0c60b7..e01bfd1 100644 --- a/scripts/altium/Library.pas +++ b/scripts/altium/Library.pas @@ -333,7 +333,7 @@ Result := BuildErrorResponse(RequestId, 'CREATE_FAILED', 'Failed to create symbol'); End; -{ Lib_SetCurrentComponent — make a named component the "current" one in } +{ Lib_SetCurrentComponent: make a named component the "current" one in } { the SchLib editor so subsequent SchIterator-based commands (modify_objects } { on ePin / eRectangle / eParameter via active_doc scope) target it. The } { asymmetry this fixes: GetState_SchComponentByLibRef is a read-only fetch } @@ -347,10 +347,16 @@ { lib_component scope handling in the generic primitives, so a caller can } { target a library symbol without a separate set_current_component round- } { trip. } -Function SelectLibComponent(Name : String) : ISch_Component; +{ SelectLibComponentPart - focus a library symbol and make PART PartId the } +{ active one. A SchLib iterator only ever yields the CURRENT part's } +{ primitives, so on a multi-part symbol every query, modify and delete sees } +{ part 1 alone unless the caller can move the part pointer. PartId <= 0 keeps } +{ the historical part-1 behaviour. } +Function SelectLibComponentPart(Name : String; PartId : Integer) : ISch_Component; Var SchLib : ISch_Lib; Component : ISch_Component; + Target, Count : Integer; Begin Result := Nil; If (Name = '') Or (SchServer = Nil) Then Exit; @@ -363,18 +369,37 @@ SchLib.CurrentSchComponent := Component; LastCreatedLibComponent := Component; + { Reset PartID + DisplayMode so subsequent Lib_AddSymbol* calls write } - { their primitives onto the visible normal-mode part (Part 1, DisplayMode } - { 0). Without this, after a fresh SchLib reopen Component.CurrentPartID } - { can be 0 (no part) and AddSchObject silently succeeds but the primitive } - { lands on an invisible bucket -- explains the "line added with success } - { but no eLine in query_objects" behaviour observed 2026-05-16. } - Try Component.CurrentPartID := 1; Except End; + { their primitives onto a VISIBLE normal-mode part. Without this, after a } + { fresh SchLib reopen Component.CurrentPartID can be 0 (no part) and } + { AddSchObject silently succeeds but the primitive lands on an invisible } + { bucket -- explains the "line added with success but no eLine in } + { query_objects" behaviour observed 2026-05-16. The reset stays; the only } + { change is WHICH part it selects when the caller asks for one. } + Target := 1; + If PartId > 1 Then + Begin + Count := 1; + Try Count := Component.PartCount; Except End; + { PartCount can read high by one on some symbols; clamp rather than } + { refuse, and never below 1. } + If (Count > 0) And (PartId <= Count) Then + Target := PartId + Else + Target := PartId; { let Altium reject an out-of-range id } + End; + Try Component.CurrentPartID := Target; Except End; Try Component.DisplayMode := 0; Except End; Try SchLib.GraphicallyInvalidate; Except End; Result := Component; End; +Function SelectLibComponent(Name : String) : ISch_Component; +Begin + Result := SelectLibComponentPart(Name, 1); +End; + Function Lib_SetCurrentComponent(Params : String; RequestId : String) : String; Var Name : String; @@ -470,6 +495,7 @@ Function Lib_AddSymbolRectangle(Params : String; RequestId : String) : String; Var X1, Y1, X2, Y2 : Integer; + FillColorStr, BorderColorStr : String; SchLib : ISch_Lib; Component : ISch_Component; Rect : ISch_Rectangle; @@ -479,6 +505,8 @@ Y1 := StrToIntDef(ExtractJsonValue(Params, 'y1'), 0); X2 := StrToIntDef(ExtractJsonValue(Params, 'x2'), 0); Y2 := StrToIntDef(ExtractJsonValue(Params, 'y2'), 0); + FillColorStr := ExtractJsonValue(Params, 'fill_color'); + BorderColorStr := ExtractJsonValue(Params, 'border_color'); SchLib := SchServer.GetCurrentSchDocument; If (SchLib = Nil) Or (SchLib.ObjectId <> eSchLib) Then @@ -509,7 +537,27 @@ Loc.X := MilsToCoord(X2); Loc.Y := MilsToCoord(Y2); Rect.Corner := Loc; + + { Colours are OPTIONAL and only touched when supplied, so a caller } + { that sends neither gets exactly the outline it got before. } + { } + { IsSolid is the reason fill_color did nothing: it was pinned False } + { here, so an AreaColor would never have been drawn. A supplied } + { fill therefore turns the rectangle solid as well, which is what } + { lib_create_ic_symbol has been asking for all along by sending } + { Altium's pale-yellow body colour and getting a hollow box. } + { -1 is the tool's documented "no fill" sentinel and is what the } + { parameter DEFAULTS to, so it arrives on nearly every call. } + { Treating any non-empty value as a fill would have turned every } + { symbol rectangle solid in colour -1. } Rect.IsSolid := False; + If BorderColorStr <> '' Then + Try Rect.Color := StrToIntDef(BorderColorStr, 0); Except End; + If (FillColorStr <> '') And (StrToIntDef(FillColorStr, -1) >= 0) Then + Try + Rect.AreaColor := StrToIntDef(FillColorStr, 0); + Rect.IsSolid := True; + Except End; SchServer.ProcessControl.PreProcess(SchLib, ''); SetOwnerPart(Rect, Component); @@ -1031,6 +1079,8 @@ Function Lib_AddFootprintText(Params : String; RequestId : String) : String; Var TextStr, LayerStr, CompName, LibPath, FocusedPath, FlagStr, RespJson : String; + MirrorStr : String; + Mirror : Boolean; Workspace : IWorkspace; Doc : IDocument; PcbLib : IPCB_Library; @@ -1057,6 +1107,11 @@ If LayerStr = '' Then LayerStr := 'TopOverlay'; FlagStr := ExtractJsonValue(Params, 'use_ttfont'); UseTTFont := (FlagStr = 'true') Or (FlagStr = 'True') Or (FlagStr = '1'); + { Bottom-side text must be mirrored or it reads backwards on the } + { board. audit_find_mirrored_pcb_text reports exactly this pairing: } + { eBottomOverlay without MirrorFlag, and eTopOverlay with it. } + MirrorStr := ExtractJsonValue(Params, 'mirror'); + Mirror := (MirrorStr = 'true') Or (MirrorStr = 'True') Or (MirrorStr = '1'); LibPath := ExtractJsonValue(Params, 'library_path'); LibPath := StringReplace(LibPath, '\\', '\', -1); CompName := ExtractJsonValue(Params, 'component_name'); @@ -1142,6 +1197,7 @@ Text.UnderlyingString := TextStr; Text.Size := MilsToCoord(Size); Text.Width := MilsToCoord(Width); + Try Text.MirrorFlag := Mirror; Except End; Try Text.Rotation := Rotation; Except End; { The working pattern: add to footprint AND to its } @@ -3009,7 +3065,10 @@ { not applied (Altium ignores them on import; set in the editor). } Function Lib_Link3DModel(Params : String; RequestId : String) : String; Var - ModelPath, ComponentName, FpName : String; + ModelPath, ComponentName, FpName, AppliedJson : String; + OffX, OffY, OffZ : Integer; + RotZ : Double; + DidStandoff, DidRotation, DidMove : Boolean; PcbLib : IPCB_Library; Footprint : IPCB_LibComponent; Iter : IPCB_LibraryIterator; @@ -3019,6 +3078,15 @@ ModelPath := ExtractJsonValue(Params, 'model_path'); ModelPath := StringReplace(ModelPath, '\\', '\', -1); ComponentName := ExtractJsonValue(Params, 'component_name'); + { Mils and degrees, matching the tool's documented units. } + { rotation_x / rotation_y are deliberately NOT read: the body exposes } + { StandoffHeight and a PLANAR Rotation, and the PCB API reference } + { gives the model no X or Y tilt, so reading them would imply a } + { capability that does not exist. } + OffX := StrToIntDef(ExtractJsonValue(Params, 'offset_x'), 0); + OffY := StrToIntDef(ExtractJsonValue(Params, 'offset_y'), 0); + OffZ := StrToIntDef(ExtractJsonValue(Params, 'offset_z'), 0); + RotZ := StrToFloatDef(ExtractJsonValue(Params, 'rotation_z'), 0.0); If (ModelPath = '') Or (Not FileExists(ModelPath)) Then Begin @@ -3088,9 +3156,41 @@ Body.SetState_FromModel; Body.Model := Model; Footprint.AddPCBObject(Body); - Result := BuildSuccessResponse(RequestId, - '{"success":true,"footprint":"' + EscapeJsonString(FpName) + - '","model":"' + EscapeJsonString(ExtractFileName(ModelPath)) + '"}'); + + { Placement adjustments. Each is guarded AND REPORTED: } + { StandoffHeight, Rotation and MoveByXY are documented on } + { the body (MoveByXY via IPCB_Primitive, used on other } + { primitives in PCB.pas) but appear nowhere else in this } + { codebase, so the first live run needs to show which ones } + { actually took rather than trusting a blanket success. } + DidStandoff := False; + DidRotation := False; + DidMove := False; + If OffZ <> 0 Then + Try + Body.StandoffHeight := MilsToCoord(OffZ); + DidStandoff := True; + Except End; + If RotZ <> 0 Then + Try + Body.Rotation := RotZ; + DidRotation := True; + Except End; + If (OffX <> 0) Or (OffY <> 0) Then + Try + Body.MoveByXY(MilsToCoord(OffX), MilsToCoord(OffY)); + DidMove := True; + Except End; + + AppliedJson := JsonBool('standoff_height', DidStandoff) + ',' + + JsonBool('rotation_z', DidRotation) + ',' + + JsonBool('offset_xy', DidMove); + + Result := BuildSuccessResponse(RequestId, JsonObj( + JsonBool('success', True) + ',' + + JsonStr('footprint', FpName) + ',' + + JsonStr('model', ExtractFileName(ModelPath)) + ',' + + JsonRaw('applied', JsonObj(AppliedJson)))); End; End; Finally @@ -4781,7 +4881,11 @@ { Params: pins = '~~'-separated list; each pin has key=value fields joined by } { ';'. Fields: designator, name, x, y, length (mils), rotation } { (0/90/180/270), electrical_type (input/output/bidirectional/ } -{ passive/power/open_collector/open_emitter/hiz), hidden (true/false).} +{ passive/power/open_collector/open_emitter/hiz), hidden (true/false),} +{ symbol_outer_edge / symbol_inner_edge (IEEE decoration name or } +{ ordinal; 'dot' = inversion bubble, 'clock' = clock wedge), } +{ show_name / show_designator (true/false; whether the pin's name } +{ and number are drawn. Omit to leave at Altium's default). } {..............................................................................} Function Lib_AddPins(Params : String; RequestId : String) : String; @@ -4789,6 +4893,7 @@ PinsStr, Op, Remaining : String; OpCount, Added, Failed : Integer; Designator, Name, ElecType, HiddenStr, OwnerStr : String; + OuterStr, InnerStr, ShowNameStr, ShowDesigStr : String; X, Y, Length, Rotation, OwnerPartId : Integer; Hidden, OwnerExplicit : Boolean; SchLib : ISch_Lib; @@ -4845,6 +4950,17 @@ OwnerStr := GetBatchField(Op, 'owner_part_id'); OwnerExplicit := OwnerStr <> ''; OwnerPartId := StrToIntDef(OwnerStr, 0); + { IEEE edge decorations: 'dot' on the outer edge is the inversion } + { bubble of an active-low pin, 'clock' on the inner edge is the } + { wedge of a clock pin. Any TIeeeSymbol name or ordinal is } + { accepted; see StrToIeeeSymbol. } + OuterStr := GetBatchField(Op, 'symbol_outer_edge'); + InnerStr := GetBatchField(Op, 'symbol_inner_edge'); + { Whether the pin's name and number are DRAWN. Distinct from } + { 'hidden', which hides the whole pin: a resistor shows both } + { its pins and neither of their labels. Absent = leave alone. } + ShowNameStr := GetBatchField(Op, 'show_name'); + ShowDesigStr := GetBatchField(Op, 'show_designator'); Pin := SchServer.SchObjectFactory(ePin, eCreate_Default); If Pin = Nil Then @@ -4866,6 +4982,22 @@ Pin.Electrical := StrToPinElectrical(ElecType); + { Only written when the caller asked for a decoration. A fresh } + { pin already carries eNoSymbol on both edges, so skipping the } + { assignment keeps this bulk path (every symbol we author runs } + { through it) byte-identical to its previous behaviour whenever } + { the new fields are absent. } + If OuterStr <> '' Then + Pin.Symbol_OuterEdge := StrToIeeeSymbol(OuterStr); + If InnerStr <> '' Then + Pin.Symbol_InnerEdge := StrToIeeeSymbol(InnerStr); + + If ShowNameStr <> '' Then + Pin.ShowName := (ShowNameStr = 'true') Or (ShowNameStr = '1'); + If ShowDesigStr <> '' Then + Pin.ShowDesignator := + (ShowDesigStr = 'true') Or (ShowDesigStr = '1'); + If OwnerExplicit Then Begin { Explicit owner_part_id from caller (multi-part symbol). } @@ -4890,6 +5022,147 @@ + ',"total":' + IntToStr(OpCount) + '}'); End; +{..............................................................................} +{ Lib_AddSymbolText - Bulk add body text to the current library symbol. } +{ Same batch shape as Lib_AddPins: one PreProcess/PostProcess for the lot. } +{ Params: texts = '~~'-separated list; fields joined by ';'. Fields: text, } +{ x, y (mils), rotation (0/90/180/270), font_size, font_name, bold, } +{ italic (true/false), owner_part_id. } +{ } +{ The primitive is an ISch_Label, which is what Altium uses for free text on } +{ a symbol. Its property set is the one BuildLabelStyleJson already reads } +{ (Text / FontId / Location / Orientation / Justification), so nothing new is } +{ being assumed about the interface. } +{ } +{ font_size is Altium's own font size, the number SchServer.FontManager } +{ takes, NOT mils. No conversion is attempted here because the relationship } +{ between the two is not documented anywhere this project can check, and a } +{ guessed constant would silently resize every imported note. } +{..............................................................................} + +Function Lib_AddSymbolText(Params : String; RequestId : String) : String; +Var + TextsStr, Op, Remaining : String; + OpCount, Added, Failed : Integer; + Content, OwnerStr, FontName, BoldStr, ItalicStr : String; + X, Y, Rotation, FontSize, OwnerPartId : Integer; + OwnerExplicit, Bold, Italic : Boolean; + SchLib : ISch_Lib; + Component : ISch_Component; + Lbl : ISch_Label; + Loc : TLocation; + FontMgr : ISch_FontManager; +Begin + TextsStr := ExtractJsonValue(Params, 'texts'); + If TextsStr = '' Then + Begin + Result := BuildErrorResponse(RequestId, 'MISSING_PARAM', 'texts is required'); + Exit; + End; + + SchLib := SchServer.GetCurrentSchDocument; + If (SchLib = Nil) Or (SchLib.ObjectId <> eSchLib) Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_SCHLIB', 'No schematic library is active'); + Exit; + End; + + Component := GetTargetLibComponent(SchLib); + If Component = Nil Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_COMPONENT', 'No component is selected'); + Exit; + End; + + FontMgr := SchServer.FontManager; + + Added := 0; + Failed := 0; + OpCount := 0; + Remaining := TextsStr; + + SchServer.ProcessControl.PreProcess(SchLib, ''); + Try + While True Do + Begin + Op := NextBatchOp(Remaining); + If Op = '' Then Break; + OpCount := OpCount + 1; + + Content := GetBatchField(Op, 'text'); + If Content = '' Then + Begin + { An empty string would place an invisible, unselectable } + { primitive that only shows up as a stray object later. } + Inc(Failed); + Continue; + End; + + X := StrToIntDef(GetBatchField(Op, 'x'), 0); + Y := StrToIntDef(GetBatchField(Op, 'y'), 0); + Rotation := StrToIntDef(GetBatchField(Op, 'rotation'), 0); + FontSize := StrToIntDef(GetBatchField(Op, 'font_size'), 10); + FontName := GetBatchField(Op, 'font_name'); + If FontName = '' Then FontName := 'Arial'; + BoldStr := GetBatchField(Op, 'bold'); + ItalicStr := GetBatchField(Op, 'italic'); + Bold := (BoldStr = 'true') Or (BoldStr = '1'); + Italic := (ItalicStr = 'true') Or (ItalicStr = '1'); + OwnerStr := GetBatchField(Op, 'owner_part_id'); + OwnerExplicit := OwnerStr <> ''; + OwnerPartId := StrToIntDef(OwnerStr, 0); + + Lbl := SchServer.SchObjectFactory(eLabel, eCreate_Default); + If Lbl = Nil Then + Begin + Inc(Failed); + Continue; + End; + + Lbl.Text := Content; + { Location is a by-value record: read, mutate, write back. } + Loc := Lbl.Location; + Loc.X := MilsToCoord(X); + Loc.Y := MilsToCoord(Y); + Lbl.Location := Loc; + + { Orientation is enum-typed. Assign the quarter-turn ordinal as } + { a plain Integer, exactly as Lib_AddPins sets Pin.Orientation, } + { rather than naming a type this codebase cannot verify. } + Try + Lbl.Orientation := (((Rotation Mod 360) + 360) Mod 360) Div 90; + Except + End; + + Try + Lbl.FontId := FontMgr.GetFontID(FontSize, 0, False, Italic, + Bold, False, FontName); + Except + End; + + If OwnerExplicit Then + Begin + Try Lbl.OwnerPartId := OwnerPartId; Except End; + Try Lbl.OwnerPartDisplayMode := 0; Except End; + End + Else + SetOwnerPart(Lbl, Component); + + Component.AddSchObject(Lbl); + SchRegisterObject(Component, Lbl); + Inc(Added); + End; + Finally + SchServer.ProcessControl.PostProcess(SchLib, 'Edit'); + End; + + MarkLibDirty(SchLib); + + Result := BuildSuccessResponse(RequestId, + '{"added":' + IntToStr(Added) + ',"failed":' + IntToStr(Failed) + + ',"total":' + IntToStr(OpCount) + '}'); +End; + { Batch line authoring: same shape as Lib_AddPins. Receives a `lines` array } { encoded with the ~~ / ; / = separators NextBatchOp expects, applies them } { all inside one PreProcess / PostProcess pair, and triggers a single } @@ -7721,6 +7994,135 @@ Result := BuildSuccessResponse(RequestId, RespJson); End; +{ Lib_ClearSourceLibrary - unpin every symbol in a SchLib from its source } +{ provenance, the library-side sibling of the placed-component } +{ clear_sch_source_library. When symbols were copied in from another library } +{ (a vendor pack, a stock library) each carries SourceLibraryName / } +{ TargetFileName pointing at the ORIGIN, and a stale DesignItemId; placing } +{ them then re-links against a library that no longer exists. Per matching } +{ component: clear SourceLibraryName, reset TargetFileName to '*', and sync } +{ DesignItemId to the LibReference (each independently switchable). The } +{ minimal fast path of what lib_normalize_implementations does as part of } +{ its full model sweep. Deferred save via MarkLibDirty. } +{ Params: library_path (optional, focused default), } +{ component_names (optional comma list, empty = all), } +{ clear_target_file_name=true, sync_design_item_id=true. } +Function Lib_ClearSourceLibrary(Params : String; RequestId : String) : String; +Var + LibPath, NamesCsv, FlagStr, Nm, LibRef : String; + SchLib : ISch_Lib; + CompIter : ISch_Iterator; + Component : ISch_Component; + AllNames, WantNames : TStringList; + ClearTarget, SyncId, WantAll : Boolean; + C, Total, ClearedSrc, ClearedTgt, Synced : Integer; +Begin + LibPath := StringReplace(ExtractJsonValue(Params, 'library_path'), '\\', '\', -1); + NamesCsv := ExtractJsonValue(Params, 'component_names'); + FlagStr := ExtractJsonValue(Params, 'clear_target_file_name'); + ClearTarget := Not ((FlagStr = 'false') Or (FlagStr = 'False') Or (FlagStr = '0')); + FlagStr := ExtractJsonValue(Params, 'sync_design_item_id'); + SyncId := Not ((FlagStr = 'false') Or (FlagStr = 'False') Or (FlagStr = '0')); + + SchLib := FocusSchLib(LibPath); + If SchLib = Nil Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_SCHLIB', + 'Failed to focus schematic library at ' + LibPath); + Exit; + End; + + AllNames := TStringList.Create; + WantNames := TStringList.Create; + Try + WantNames.CommaText := NamesCsv; + WantAll := WantNames.Count = 0; + + { Two-phase walk (the normalize pattern): collect names via the } + { live iterator first, mutate by LibRef lookup after, so the } + { iterator never sees a component being modified under it. } + CompIter := SchLib.SchLibIterator_Create; + Try + CompIter.AddFilter_ObjectSet(MkSet(eSchComponent)); + Component := CompIter.FirstSchObject; + While Component <> Nil Do + Begin + Nm := ''; + Try Nm := Component.LibReference; Except End; + If Nm <> '' Then + Begin + If WantAll Or (WantNames.IndexOf(Nm) >= 0) Then + AllNames.Add(Nm); + End; + Component := CompIter.NextSchObject; + End; + Finally + SchLib.SchIterator_Destroy(CompIter); + End; + + Total := 0; + ClearedSrc := 0; + ClearedTgt := 0; + Synced := 0; + + SchServer.ProcessControl.PreProcess(SchLib, ''); + Try + For C := 0 To AllNames.Count - 1 Do + Begin + Component := SchLib.GetState_SchComponentByLibRef(AllNames[C]); + If Component = Nil Then Continue; + Inc(Total); + + Try + If Component.SourceLibraryName <> '' Then + Begin + Component.SourceLibraryName := ''; + Inc(ClearedSrc); + End; + Except End; + + If ClearTarget Then + Begin + Try + If Component.TargetFileName <> '*' Then + Begin + Component.TargetFileName := '*'; + Inc(ClearedTgt); + End; + Except End; + End; + + If SyncId Then + Begin + Try + LibRef := Component.LibReference; + If (LibRef <> '') And (Component.DesignItemId <> LibRef) Then + Begin + Component.DesignItemId := LibRef; + Inc(Synced); + End; + Except End; + End; + End; + Finally + SchServer.ProcessControl.PostProcess(SchLib, 'Edit'); + End; + + SchLib.GraphicallyInvalidate; + MarkLibDirty(SchLib); + Finally + AllNames.Free; + WantNames.Free; + End; + + Result := BuildSuccessResponse(RequestId, + '{"library_path":"' + EscapeJsonString(LibPath) + '"' + + ',"total":' + IntToStr(Total) + + ',"cleared_source_library":' + IntToStr(ClearedSrc) + + ',"cleared_target_file_name":' + IntToStr(ClearedTgt) + + ',"synced_design_item_id":' + IntToStr(Synced) + '}'); +End; + {..............................................................................} { Command Handler - must be at end } {..............................................................................} @@ -7731,6 +8133,7 @@ 'create_symbol': Result := Lib_CreateSymbol(Params, RequestId); 'add_pin': Result := Lib_AddPin(Params, RequestId); 'add_pins': Result := Lib_AddPins(Params, RequestId); + 'add_symbol_text': Result := Lib_AddSymbolText(Params, RequestId); 'add_symbol_rectangle': Result := Lib_AddSymbolRectangle(Params, RequestId); 'add_symbol_line': Result := Lib_AddSymbolLine(Params, RequestId); 'add_symbol_lines': Result := Lib_AddSymbolLines(Params, RequestId); @@ -7784,6 +8187,7 @@ 'probe_footprint': Result := Lib_ProbeFootprint(Params, RequestId); 'get_pad_geometry': Result := Lib_GetPadGeometry(Params, RequestId); 'normalize_implementations': Result := Lib_NormalizeImplementations(Params, RequestId); + 'clear_source_library': Result := Lib_ClearSourceLibrary(Params, RequestId); Else Result := BuildErrorResponse(RequestId, 'UNKNOWN_ACTION', 'Unknown library action: ' + Action); End; diff --git a/scripts/altium/Main.pas b/scripts/altium/Main.pas index 3293fdb..172899a 100644 --- a/scripts/altium/Main.pas +++ b/scripts/altium/Main.pas @@ -13,7 +13,7 @@ // returns, mismatch means Altium is running a stale compiled script // (DelphiScript caches compiled units until the script project is // reopened or Altium is restarted). - SCRIPT_VERSION = '2026.07.25.3'; + SCRIPT_VERSION = '2026.08.05.1'; // Wire protocol version. Bumped whenever the request/response JSON shape // changes incompatibly. Python and Pascal must agree; mismatch returns diff --git a/scripts/altium/PCB.pas b/scripts/altium/PCB.pas index 19ad9b6..b50c708 100644 --- a/scripts/altium/PCB.pas +++ b/scripts/altium/PCB.pas @@ -7942,6 +7942,162 @@ )); End; +{..............................................................................} +{ PCB_ApplyDnpPasteExclusion - suppress stencil paste on Not-Fitted parts. } +{ Params: designators (pipe-separated), restore (true/false) } +{ } +{ A Not-Fitted component is on the BOM as a placeholder and must NOT receive } +{ paste: the SMT line would otherwise deposit paste on empty pads, and the } +{ bridging shows up as rework. This is the remediation half of } +{ audit.variant_not_fitted, which is the identify half. The designator list } +{ is passed IN rather than re-detected here, so the mutation is reviewable } +{ and a caller can override the selection; detection stays in one place. } +{ } +{ Mechanism: per-pad PasteMaskExpansion set manual and negative, which is } +{ what PCB_MakePasteGrid already does to clear a pad before laying its grid. } +{ An expansion of minus the larger pad dimension collapses the aperture } +{ whatever the shape. } +{ } +{ restore=true puts PasteMaskExpansionValid back to eCacheInvalid, which } +{ discards the manual override and makes Altium recompute from the design } +{ rules. TCacheState is (eCacheInvalid, eCacheValid, eCacheManual); there is } +{ no "use the rule" member, and eCacheValid would assert that a rule-derived } +{ value already sits in the field, which after an override it does not. } +{ } +{ Only surface pads are touched. A multi-layer (through-hole) pad gets no } +{ stencil aperture anyway, so overriding it would be a no-op recorded as a } +{ change; those are counted and reported separately instead. } +{..............................................................................} + +Function PCB_ApplyDnpPasteExclusion(Params : String; RequestId : String) : String; +Var + Board : IPCB_Board; + Iterator : IPCB_BoardIterator; + GrpIter : IPCB_GroupIterator; + Comp : IPCB_Component; + Pad : IPCB_Pad; + Cache : TPadCache; + DesigList, RestoreStr, CompDesig, Matched, ItemsJson, EntryJson : String; + Restore, First : Boolean; + PadsChanged, PadsSkippedTht, CompsMatched, CompsRequested : Integer; + PadW, PadH, Expansion, CompPads : Integer; +Begin + DesigList := ExtractJsonValue(Params, 'designators'); + If DesigList = '' Then + Begin + Result := BuildErrorResponse(RequestId, 'MISSING_PARAM', + 'designators is required (pipe-separated); run ' + + 'audit.variant_not_fitted first to get the Not-Fitted list'); + Exit; + End; + RestoreStr := LowerCase(ExtractJsonValue(Params, 'restore')); + Restore := (RestoreStr = 'true') Or (RestoreStr = '1'); + + Board := GetPCBBoardAnywhere; + If Board = Nil Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_PCB', + 'No PCB document is active'); + Exit; + End; + + { Count what was asked for, so the caller can see whether every named } + { component was actually found on this board. } + CompsRequested := 1; + Matched := DesigList; + While Pos('|', Matched) > 0 Do + Begin + CompsRequested := CompsRequested + 1; + Matched := Copy(Matched, Pos('|', Matched) + 1, Length(Matched)); + End; + + PadsChanged := 0; + PadsSkippedTht := 0; + CompsMatched := 0; + ItemsJson := ''; + First := True; + + PCBServer.PreProcess; + Try + Iterator := Board.BoardIterator_Create; + Try + Iterator.AddFilter_ObjectSet(MkSet(eComponentObject)); + Iterator.AddFilter_LayerSet(AllLayers); + Iterator.AddFilter_Method(eProcessAll); + Comp := Iterator.FirstPCBObject; + While Comp <> Nil Do + Begin + CompDesig := ''; + Try CompDesig := Comp.Name.Text; Except End; + { Pipe-delimited membership, anchored so R1 does not match } + { R10. } + If (CompDesig <> '') + And (Pos('|' + CompDesig + '|', '|' + DesigList + '|') > 0) Then + Begin + Inc(CompsMatched); + CompPads := 0; + GrpIter := Comp.GroupIterator_Create; + Try + GrpIter.AddFilter_ObjectSet(MkSet(ePadObject)); + Pad := GrpIter.FirstPCBObject; + While Pad <> Nil Do + Begin + { Surface pads only; a through-hole pad has no } + { stencil aperture to suppress. } + If (Pad.Layer = eTopLayer) Or (Pad.Layer = eBottomLayer) Then + Begin + Try + Cache := Pad.GetState_Cache; + If Restore Then + Cache.PasteMaskExpansionValid := eCacheInvalid + Else + Begin + PadW := Pad.TopXSize; + PadH := Pad.TopYSize; + If PadW > PadH Then Expansion := -PadW + Else Expansion := -PadH; + Cache.PasteMaskExpansionValid := eCacheManual; + Cache.PasteMaskExpansion := Expansion; + End; + Pad.SetState_Cache := Cache; + Inc(PadsChanged); + CompPads := CompPads + 1; + Except End; + End + Else + Inc(PadsSkippedTht); + Pad := GrpIter.NextPCBObject; + End; + Finally + Comp.GroupIterator_Destroy(GrpIter); + End; + If Not First Then ItemsJson := ItemsJson + ','; + First := False; + EntryJson := JsonStr('designator', CompDesig) + ',' + + JsonInt('pads_changed', CompPads); + ItemsJson := ItemsJson + JsonObj(EntryJson); + End; + Comp := Iterator.NextPCBObject; + End; + Finally + Board.BoardIterator_Destroy(Iterator); + End; + Finally + PCBServer.PostProcess; + End; + + Try Board.GraphicallyInvalidate; Except End; + + Result := BuildSuccessResponse(RequestId, JsonObj( + JsonBool('restored', Restore) + ',' + + JsonInt('components_requested', CompsRequested) + ',' + + JsonInt('components_matched', CompsMatched) + ',' + + JsonInt('pads_changed', PadsChanged) + ',' + + JsonInt('pads_skipped_through_hole', PadsSkippedTht) + ',' + + JsonRaw('items', JsonArr(ItemsJson)))); +End; + + { PCB_GetDifferentialPairs } { } @@ -11077,6 +11233,7 @@ 'clear_source_footprint_library': Result := PCB_ClearSourceFootprintLibrary(Params, RequestId); 'get_differential_pairs': Result := PCB_GetDifferentialPairs(Params, RequestId); 'make_paste_grid': Result := PCB_MakePasteGrid(Params, RequestId); + 'apply_dnp_paste_exclusion': Result := PCB_ApplyDnpPasteExclusion(Params, RequestId); 'add_testpoints_for_net_class': Result := PCB_AddTestpointsForNetClass(Params, RequestId); 'check_placement_collision': Result := PCB_CheckPlacementCollision(Params, RequestId); 'get_trace_lengths': Result := PCB_GetTraceLengths(Params, RequestId); diff --git a/scripts/altium/SelfTest.pas b/scripts/altium/SelfTest.pas index edf4929..65470cc 100644 --- a/scripts/altium/SelfTest.pas +++ b/scripts/altium/SelfTest.pas @@ -632,6 +632,70 @@ { Main Entry Point } {..............................................................................} +{..............................................................................} +{ TestIeeeSymbolConverters - the IEEE pin decoration vocabulary. } +{ } +{ These converters are cross-validated against a real Pascal compiler by } +{ tests/cross_validate_pascal.pas, which carries them VERBATIM and is built } +{ by Free Pascal. That proves the token walk and the ordinals, and it cannot } +{ prove the one thing that matters most here: that DelphiScript itself } +{ compiles and runs them. FPC accepts identifiers Altium's engine rejects and } +{ the reverse, and an undeclared identifier faults at RUNTIME where } +{ Try/Except cannot catch it. } +{ } +{ Running inside Altium is therefore the only check that closes the gap. It } +{ needs no document, so it belongs with the pure-logic tests above. } +{..............................................................................} + +Procedure TestIeeeSymbolConverters; +Begin + { The two that carry schematic meaning. eDot draws the inversion } + { bubble of an active-low pin, eClock the wedge of a clock pin. } + { Ordinals verified against the schematic API types reference. } + AssertEqual(IntToStr(StrToIeeeSymbol('dot')), '1', 'IEEE dot is 1'); + AssertEqual(IntToStr(StrToIeeeSymbol('clock')), '3', 'IEEE clock is 3'); + + { Spread across the enum, so a shifted list cannot pass by getting } + { only the first few right. } + AssertEqual(IntToStr(StrToIeeeSymbol('no_symbol')), '0', 'IEEE no_symbol is 0'); + AssertEqual(IntToStr(StrToIeeeSymbol('active_low_input')), '4', 'IEEE active_low_input is 4'); + AssertEqual(IntToStr(StrToIeeeSymbol('open_collector')), '9', 'IEEE open_collector is 9'); + AssertEqual(IntToStr(StrToIeeeSymbol('active_low_output')), '17', 'IEEE active_low_output is 17'); + AssertEqual(IntToStr(StrToIeeeSymbol('bidirectional_signal_flow')), '34', + 'IEEE bidirectional_signal_flow is 34'); + + { Aliases and Altium's own raw enum spelling. } + AssertEqual(IntToStr(StrToIeeeSymbol('inverted')), '1', 'IEEE alias inverted'); + AssertEqual(IntToStr(StrToIeeeSymbol(' INVERTED ')), '1', 'IEEE alias is trimmed and case-folded'); + AssertEqual(IntToStr(StrToIeeeSymbol('active_low')), '1', 'IEEE alias active_low'); + AssertEqual(IntToStr(StrToIeeeSymbol('clk')), '3', 'IEEE alias clk'); + AssertEqual(IntToStr(StrToIeeeSymbol('eDot')), '1', 'IEEE raw enum eDot'); + AssertEqual(IntToStr(StrToIeeeSymbol('eClock')), '3', 'IEEE raw enum eClock'); + + { A bare ordinal reaches members with no friendly name; anything } + { unrecognised, out of range or negative becomes eNoSymbol rather } + { than a wrong decoration. } + AssertEqual(IntToStr(StrToIeeeSymbol('9')), '9', 'IEEE bare ordinal passes through'); + AssertEqual(IntToStr(StrToIeeeSymbol('35')), '0', 'IEEE ordinal past the end is refused'); + AssertEqual(IntToStr(StrToIeeeSymbol('-3')), '0', 'IEEE negative ordinal is refused'); + AssertEqual(IntToStr(StrToIeeeSymbol('nonsense')), '0', 'IEEE unknown name is refused'); + AssertEqual(IntToStr(StrToIeeeSymbol('')), '0', 'IEEE empty name is refused'); + AssertEqual(IntToStr(StrToIeeeSymbol('e')), '0', 'IEEE lone e is refused'); + + { Naming an ordinal back, used when reporting a pin's decoration. } + AssertEqual(IeeeSymbolToStr(1), 'dot', 'IEEE name of 1'); + AssertEqual(IeeeSymbolToStr(3), 'clock', 'IEEE name of 3'); + AssertEqual(IeeeSymbolToStr(0), 'no_symbol', 'IEEE name of 0'); + AssertEqual(IeeeSymbolToStr(99), 'no_symbol', 'IEEE name of an unknown ordinal'); + AssertEqual(IeeeSymbolToStr(-1), 'no_symbol', 'IEEE name of a negative ordinal'); + + { StripChar is written out rather than calling StringReplace so the } + { same source compiles under both DelphiScript and Free Pascal. } + AssertEqual(StripChar('a_b_c', '_'), 'abc', 'StripChar removes every occurrence'); + AssertEqual(StripChar('abc', '_'), 'abc', 'StripChar leaves a clean string alone'); + AssertEqual(StripChar('', '_'), '', 'StripChar handles an empty string'); +End; + Procedure RunSelfTest; Var Summary : String; @@ -652,6 +716,7 @@ TestStringHelpers; TestObjectTypeMappings; TestLayerMappings; + TestIeeeSymbolConverters; TestFileIO; TestEdgeCases; TestRunProcessParsing; diff --git a/scripts/altium/StatusForm.pas b/scripts/altium/StatusForm.pas index 882343e..d9c7734 100644 --- a/scripts/altium/StatusForm.pas +++ b/scripts/altium/StatusForm.pas @@ -610,7 +610,7 @@ Try pnl_StatusDot.Color := COLOR_ACCENT_GREEN; Except End; Try lbl_Status.Caption := 'idle'; Except End; Try lbl_LastErr.Caption := ''; Except End; - { Button is always enabled — dashboard can run standalone. } + { Button is always enabled: dashboard can run standalone. } UpdateOpenWebState; Except End; End; diff --git a/scripts/altium/Utils.pas b/scripts/altium/Utils.pas index 699680e..bb99855 100644 --- a/scripts/altium/Utils.pas +++ b/scripts/altium/Utils.pas @@ -325,6 +325,133 @@ Result := True; End; +{..............................................................................} +{ IEEE pin-symbol (TIeeeSymbol) converters, used for the decoration drawn on } +{ a pin's inner or outer edge: the inversion bubble on an active-low pin } +{ (outer edge, 'dot') and the wedge on a clock pin (inner edge, 'clock'). } +{ } +{ These deliberately traffic in Integer, never in TIeeeSymbol. That type name } +{ appears nowhere else in this codebase, so whether DelphiScript declares it } +{ is unverified, and an undeclared identifier in a signature faults at } +{ runtime where Try/Except cannot catch it. Assigning a plain Integer to an } +{ enum-typed property is already established here: Lib_AddPins sets } +{ Pin.Orientation (a TRotationBy90) from Rotation Div 90. } +{ } +{ Position in IeeeSymbolNames IS the enum ordinal, so the two converters } +{ below cannot disagree. Order verified against the schematic API types } +{ reference (TIeeeSymbol, 35 members, eNoSymbol = 0). } +{..............................................................................} + +{ Delete every occurrence of one character. Written out rather than calling } +{ StringReplace because DelphiScript spells the replace-all flag as the } +{ integer -1 while Free Pascal wants a TReplaceFlags set, and these routines } +{ are compiled by BOTH: tests/cross_validate_pascal.pas carries them } +{ verbatim so a real Pascal compiler can check them without Altium. } +Function StripChar(S : String; C : Char) : String; +Var + I : Integer; +Begin + Result := ''; + For I := 1 To Length(S) Do + If S[I] <> C Then Result := Result + S[I]; +End; + +Function IeeeSymbolNames : String; +Begin + Result := + 'no_symbol|dot|right_left_signal_flow|clock|active_low_input|' + + 'analog_signal_in|not_logic_connection|shift_right|postponed_output|' + + 'open_collector|hiz|high_current|pulse|schmitt|delay|group_line|' + + 'group_bin|active_low_output|pi_symbol|greater_equal|less_equal|' + + 'sigma|open_collector_pullup|open_emitter|open_emitter_pullup|' + + 'digital_signal_in|and|invertor|or|xor|shift_left|input_output|' + + 'open_circuit_output|left_right_signal_flow|bidirectional_signal_flow'; +End; + +Function IeeeSymbolToStr(V : Integer) : String; +Var + Names, Tok : String; + I, P : Integer; +Begin + { Unknown ordinals report as 'no_symbol' rather than raising: this feeds } + { JSON output, where a bad read must not abort the whole response. } + Result := 'no_symbol'; + If V <= 0 Then Exit; + Names := IeeeSymbolNames + '|'; + I := 0; + While Names <> '' Do + Begin + P := Pos('|', Names); + If P = 0 Then Break; + Tok := Copy(Names, 1, P - 1); + Names := Copy(Names, P + 1, Length(Names)); + If I = V Then + Begin + Result := Tok; + Exit; + End; + I := I + 1; + End; +End; + +Function StrToIeeeSymbol(S : String) : Integer; +Var + LS, Compact, Names, Tok : String; + I, P : Integer; +Begin + Result := 0; + LS := LowerCase(Trim(S)); + If LS = '' Then Exit; + + { A bare ordinal is accepted so a caller can reach any TIeeeSymbol member, } + { including the ones with no friendly alias spelled out below. } + If IsIntStr(LS) Then + Begin + Result := StrToIntDef(LS, 0); + If (Result < 0) Or (Result > 34) Then Result := 0; + Exit; + End; + + { Friendly aliases for the two that carry real schematic meaning. KiCad } + { and most part libraries describe these as "inverted" and "clock". } + Compact := StripChar(LS, '_'); + If (Compact = 'inverted') Or (Compact = 'inversion') Or (Compact = 'bubble') + Or (Compact = 'activelow') Or (Compact = 'negated') Then + Begin + Result := 1; { eDot } + Exit; + End; + If Compact = 'clk' Then + Begin + Result := 3; { eClock } + Exit; + End; + + { Altium's raw enum spelling ('eActiveLowInput') differs from the } + { canonical name only by a leading 'e', so retry once with it stripped. } + Names := IeeeSymbolNames + '|'; + I := 0; + While Names <> '' Do + Begin + P := Pos('|', Names); + If P = 0 Then Break; + Tok := StripChar(Copy(Names, 1, P - 1), '_'); + Names := Copy(Names, P + 1, Length(Names)); + If Compact = Tok Then + Begin + Result := I; + Exit; + End; + If (Length(Compact) > 1) And (Compact[1] = 'e') Then + If Copy(Compact, 2, Length(Compact)) = Tok Then + Begin + Result := I; + Exit; + End; + I := I + 1; + End; +End; + Function StrToFloatDef(S : String; Default : Double) : Double; Var OldSep : Char; From c73a563c8d9d5700b5304289a4d0139e64d2be53 Mon Sep 17 00:00:00 2001 From: George Saliba Date: Thu, 6 Aug 2026 11:20:49 +0200 Subject: [PATCH 03/16] Convert EasyEDA and KiCad parts into Altium library parts Independent implementations from the published format specs, so no third party converter is involved. Symbol, footprint, pads, drills, silkscreen, 3D model and metadata all convert. The Altium target returns an ordered plan of this server's own library tools rather than synthesizing Altium's binary formats, so parts are authored through the API. The two importers share one neutral geometry model and one Altium emitter, so they cannot drift apart. The KiCad reader handles what the standard library actually contains rather than the simple cases: derived symbols via extends, which are over half of it and would otherwise convert to a part with no pins; multi part components, which become one Altium symbol with per pin owner part ids in a single call instead of a flat merge; hidden pins, kept and kept hidden; DeMorgan body styles, one taken so pins are not duplicated; arcs recovered from start, mid and end form; active low and clock pin markers; and the closing edge of a filled outline, which fp_poly stores without repeating the final vertex and which leaves a notch in thousands of shipped footprints if consecutive pairs alone are walked. The s-expression reader is written here rather than depended on, so its escaping rules are verified rather than trusted. Geometry with no faithful equivalent is reported in warnings rather than quietly approximated. That includes the two cases that look correct after conversion and are not: a slotted drill emitted round, and an unplated hole emitted plated. --- src/eda_agent/libimport/_names.py | 53 + src/eda_agent/libimport/easyeda/__init__.py | 48 + src/eda_agent/libimport/easyeda/altium.py | 709 +++++++++ src/eda_agent/libimport/easyeda/document.py | 335 +++++ src/eda_agent/libimport/easyeda/fetch.py | 221 +++ src/eda_agent/libimport/easyeda/geometry.py | 161 ++ src/eda_agent/libimport/easyeda/kicad.py | 545 +++++++ src/eda_agent/libimport/easyeda/model3d.py | 241 +++ src/eda_agent/libimport/easyeda/shapes.py | 532 +++++++ src/eda_agent/libimport/kicad/__init__.py | 14 + src/eda_agent/libimport/kicad/reader.py | 775 ++++++++++ src/eda_agent/libimport/kicad/sexpr.py | 151 ++ tests/fixtures/easyeda_soic8.json | 41 + tests/test_easyeda_altium_plan_executes.py | 141 ++ tests/test_easyeda_converter.py | 1495 +++++++++++++++++++ 15 files changed, 5462 insertions(+) create mode 100644 src/eda_agent/libimport/_names.py create mode 100644 src/eda_agent/libimport/easyeda/__init__.py create mode 100644 src/eda_agent/libimport/easyeda/altium.py create mode 100644 src/eda_agent/libimport/easyeda/document.py create mode 100644 src/eda_agent/libimport/easyeda/fetch.py create mode 100644 src/eda_agent/libimport/easyeda/geometry.py create mode 100644 src/eda_agent/libimport/easyeda/kicad.py create mode 100644 src/eda_agent/libimport/easyeda/model3d.py create mode 100644 src/eda_agent/libimport/easyeda/shapes.py create mode 100644 src/eda_agent/libimport/kicad/__init__.py create mode 100644 src/eda_agent/libimport/kicad/reader.py create mode 100644 src/eda_agent/libimport/kicad/sexpr.py create mode 100644 tests/fixtures/easyeda_soic8.json create mode 100644 tests/test_easyeda_altium_plan_executes.py create mode 100644 tests/test_easyeda_converter.py diff --git a/src/eda_agent/libimport/_names.py b/src/eda_agent/libimport/_names.py new file mode 100644 index 0000000..436123e --- /dev/null +++ b/src/eda_agent/libimport/_names.py @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Turning third-party names into safe file names. + +Shared by every importer, because they all take a name from a payload +(a vendor part title, a registry id) and use it as a path component. +One implementation rather than one per importer: a second copy drifts, +and the copy that misses a character is the one that crashes. + +Real failures this prevents, both observed rather than theoretical: + +* ``SOT-23/5 `` as a package title raised a bare ``OSError`` + (Errno 22) out of the MCP tool, because ``/`` and ``<>`` are illegal + in a Windows path component. +* ``CON`` is a reserved device name and cannot be created with ANY + extension, so ``CON.kicad_mod`` fails too. +* A name is untrusted input, so ``../../evil`` must not escape the + directory the caller chose. +""" + +from __future__ import annotations + +__all__ = ["safe_filename"] + +#: Characters Windows forbids anywhere in a path component. +_ILLEGAL = frozenset(r'<>:"/\|?*') + +#: Reserved device names, rejected regardless of extension. +_RESERVED = frozenset({ + "CON", "PRN", "AUX", "NUL", + *(f"COM{i}" for i in range(1, 10)), + *(f"LPT{i}" for i in range(1, 10)), +}) + +#: Well under MAX_PATH once a directory and suffix are added. +_MAX_LEN = 120 + + +def safe_filename(name: str, fallback: str = "part") -> str: + r"""Make an untrusted name usable as a single path component. + + Returns ``fallback`` if nothing usable survives, so a caller never + has to handle an empty string. + """ + cleaned = "".join( + "_" if (ch in _ILLEGAL or ord(ch) < 32) else ch + for ch in str(name)) + # Trailing dots and spaces are illegal on Windows even when every + # other character is fine. + out = cleaned.strip(" .") + if out.split(".")[0].upper() in _RESERVED: + out = f"_{out}" + return out[:_MAX_LEN] or fallback diff --git a/src/eda_agent/libimport/easyeda/__init__.py b/src/eda_agent/libimport/easyeda/__init__.py new file mode 100644 index 0000000..4423edd --- /dev/null +++ b/src/eda_agent/libimport/easyeda/__init__.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""EasyEDA / LCSC component converter for KiCad and Altium. + +Independent implementation from EasyEDA's own published format +specification. No third-party converter source was consulted; in +particular the AGPL-licensed easyeda2kicad is not a reference, so this +package stays cleanly Apache-2.0 like the rest of the project. + +Layers, each usable on its own: + +* :mod:`shapes` shape-string parsing, pure and offline +* :mod:`document` normalized component model (mils, Y-up, origin relative) +* :mod:`kicad` ``.kicad_sym`` / ``.kicad_mod`` text emitters +* :mod:`altium` ordered MCP-tool install plan (no file format needed, + because the bridge already exposes library authoring) +* :mod:`fetch` optional online LCSC/EasyEDA client, stdlib only + +The parse and emit path never imports :mod:`fetch`, so a saved JSON +payload converts with no network at all, which is also how the tests +run. + +DATASHEET DISCIPLINE: an imported footprint is a vendor's drawing, not +ground truth. Audit it against the manufacturer land pattern with +``lib_audit_footprint_vs_datasheet`` before trusting it in a design. +""" + +from eda_agent.libimport.easyeda.altium import build_altium_plan +from eda_agent.libimport.easyeda.document import ( + EasyEdaComponent, + EasyEdaFootprint, + EasyEdaSymbol, + parse_component, +) +from eda_agent.libimport.easyeda.kicad import ( + footprint_to_kicad_mod, + symbol_to_kicad_sym, +) + +__all__ = [ + "EasyEdaComponent", + "EasyEdaFootprint", + "EasyEdaSymbol", + "build_altium_plan", + "footprint_to_kicad_mod", + "parse_component", + "symbol_to_kicad_sym", +] diff --git a/src/eda_agent/libimport/easyeda/altium.py b/src/eda_agent/libimport/easyeda/altium.py new file mode 100644 index 0000000..227bd98 --- /dev/null +++ b/src/eda_agent/libimport/easyeda/altium.py @@ -0,0 +1,709 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Emit an Altium install plan from a normalized EasyEDA component. + +Altium's .SchLib / .PcbLib are OLE compound documents, undocumented and +not worth synthesizing. This bridge already exposes a full library +authoring API, so the emitter produces an ORDERED PLAN of existing MCP +tool calls instead of a file: + + app_set_active_document(.SchLib) + lib_create_symbol -> lib_add_pins -> lib_add_symbol_* (body art) + app_set_active_document(.PcbLib) + lib_create_footprint -> lib_add_footprint_pads -> tracks/arcs/text + app_set_active_document(.SchLib) + lib_link_footprint + +THE STEP ORDER IS LOAD BEARING. These tools are stateful: they take no +library_path and no component name, they act on the ACTIVE document and +on the component that the preceding create call made current. Executing +the steps out of order, or dropping an app_set_active_document, edits +whichever library happens to be focused. tests assert this ordering, and +also assert every step against the real registered tool signatures, +because a plan can otherwise stay perfectly self-consistent while every +argument name is wrong. + +Same shape as :mod:`eda_agent.libimport.cse`: a pure offline function +returning ``{"ok": True, "steps": [{"tool": ..., "args": {...}}, ...]}``. +Driving Altium with the plan is the caller's job, which keeps this +module testable with no bridge and lets the agent review or edit the +plan before anything is written. + +Units are Altium's schematic/PCB mils, and the neutral model is already +mils Y-up, so no axis flip is needed here. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from eda_agent.bridge.payload import unsendable_chars +from eda_agent.libimport.easyeda.document import EasyEdaComponent +from eda_agent.libimport.easyeda.geometry import svg_arc_to_center +from eda_agent.libimport.easyeda.shapes import PIN_ELECTRIC + +__all__ = ["build_altium_plan"] + +#: Neutral electrical name -> the string lib_add_pins expects. +#: These are the exact values that tool documents; capitalised variants +#: are not accepted. +_ALTIUM_ELEC = { + "undefined": "passive", + "input": "input", + "output": "output", + "bidirectional": "bidirectional", + "power": "power", + # Altium names these exactly, and Library.pas maps the strings to + # eElectricOpenCollector / eElectricOpenEmitter / eElectricHiZ. + "open_collector": "open_collector", + "open_emitter": "open_emitter", + "hiz": "hiz", +} + +#: EasyEDA layer id -> Altium layer name for footprint primitives. +_ALTIUM_LAYER = { + 1: "TopLayer", 2: "BottomLayer", + 3: "TopOverlay", 4: "BottomOverlay", + 5: "TopPaste", 6: "BottomPaste", + 7: "TopSolder", 8: "BottomSolder", + 10: "KeepOutLayer", 11: "MultiLayer", + # 13/14 are EasyEDA's top/bottom assembly layers. They must not share + # a destination or bottom-side assembly art silently lands on the top + # layer, where it reads as a real top-side marking. + 12: "Mechanical1", 13: "Mechanical13", 14: "Mechanical14", +} + +#: Neutral layer ids that land on the BOTTOM of the board. Text here +#: must be mirrored to read correctly, since it is viewed through the +#: board. Taken from _ALTIUM_LAYER above: bottom copper, bottom +#: overlay/paste/solder, and the bottom assembly layer. +_BOTTOM_SIDE_LAYERS = frozenset({2, 4, 6, 8, 14}) + +#: EasyEDA pad shape -> the shape strings lib_add_footprint_pads takes. +_ALTIUM_PAD_SHAPE = { + "ELLIPSE": "round", + "RECT": "rectangular", + "ROUNDRECT": "roundrect", + "OVAL": "round", # round with x_size != y_size is a stadium + "POLYGON": "rectangular", +} + + +def _corner_radius_pct(ratio: float) -> int: + """Neutral corner ratio -> the percentage Altium's pad expects. + + The two measure the radius against different things, so this is not + a multiply by 100. Altium's documentation defines the value as "the + percentage of half of the shortest pad side, where 100% completely + rounds the shortest side", while KiCad's ``roundrect_rratio`` is the + radius over the WHOLE shorter side. Hence the factor of two: KiCad's + 0.25 default is 50% in Altium, and a fully rounded end is 0.5 there + and 100 here. + """ + return max(0, min(100, int(round(float(ratio or 0.0) * 200.0)))) + + +def _pin_rotation(rotation: float) -> int: + """Snap a neutral pin angle to the 0/90/180/270 lib_add_pins wants.""" + return int(round((rotation % 360) / 90.0) * 90) % 360 + + +def unsendable_in_plan(steps) -> list[tuple[str, str]]: + """(field, offending characters) for every plan value the wire flattens. + + A plan step is ``{"tool", "args"}`` and its args carry the text an + import writes into Altium. Anything above U+00FF is replaced with + ``?`` on the way across, so naming it while the plan is still a plan + is the last useful moment: afterwards the part exists and the field + is simply wrong. + + Non-strings are skipped, since a coordinate cannot be flattened. A + malformed step is tolerated rather than raising: a diagnostic that + breaks the import it is diagnosing is worse than none. + """ + found: list[tuple[str, str]] = [] + seen: set[tuple[str, str]] = set() + for step in steps or []: + tool = (step or {}).get("tool", "?") + for key, value in ((step or {}).get("args") or {}).items(): + if not isinstance(value, str): + continue + chars = unsendable_chars(value) + if not chars: + continue + entry = (f"{tool}.{key}", chars) + if entry not in seen: + seen.add(entry) + found.append(entry) + return found + + +def build_altium_plan( + comp: EasyEdaComponent, + schlib_path: str, + pcblib_path: str, + *, + symbol_name: Optional[str] = None, + footprint_name: Optional[str] = None, + include_body_art: bool = True, +) -> dict[str, Any]: + """Ordered MCP-tool plan that recreates ``comp`` in Altium. + + Args: + comp: normalized component from ``document.parse_component``. + schlib_path: destination .SchLib (must exist or be creatable by + the caller; the plan does not create libraries). + pcblib_path: destination .PcbLib. + symbol_name / footprint_name: override the names taken from the + component. + include_body_art: emit the symbol's rectangles / polylines / + circles. Off gives pins only, which is enough when the body + will be drawn by hand. + + Returns: + ``{"ok": True, "steps": [...], "warnings": [...], "summary": {...}}`` + """ + steps: list[dict[str, Any]] = [] + warnings: list[str] = list(comp.warnings) + + sym_name = symbol_name or (comp.symbol.name if comp.symbol else "") + fp_name = footprint_name or (comp.footprint.name if comp.footprint else "") + + # ---- symbol ------------------------------------------------------- + if comp.symbol is not None: + # The library tools act on the ACTIVE document and the current + # component, they take no library_path. Activating the target + # .SchLib is therefore a required first step, not a nicety. + steps.append({"tool": "app_set_active_document", "args": { + "file_path": schlib_path, + }}) + # A multi-part component (quad gate, dual op-amp) becomes a REAL + # Altium multi-part symbol rather than N separate symbols the + # user has to merge: part_count declares the sub-parts and each + # pin names its owner below. Sub-part 0 is Altium's "shared by + # every part", which is exactly what a source's shared-unit pins + # mean. + part_units = {getattr(s, "unit", 1) for s in comp.symbol.shapes + if s.kind == "pin"} + part_count = max([u for u in part_units if u > 0] or [1]) + create_args: dict[str, Any] = { + "name": sym_name, + "designator_prefix": comp.symbol.prefix or "U", + "description": comp.description or comp.mpn, + } + if part_count > 1: + create_args["part_count"] = part_count + steps.append({"tool": "lib_create_symbol", "args": create_args}) + + # A sub-part whose pins are ALL power rails is a drafting + # convention, not a functional stage: KiCad splits a dual + # op-amp's V+/V- into their own unit. Altium can express the + # same thing as pins SHARED by every part (owner_part_id 0), + # which many libraries prefer. The source structure is kept + # rather than reinterpreted, because both forms are legitimate + # and only one of them is what the file actually says; the + # choice is surfaced instead of made silently. + for unit_id in sorted(u for u in part_units if u > 0): + kinds = {PIN_ELECTRIC.get(s.electric, "undefined") + for s in comp.symbol.shapes + if s.kind == "pin" and getattr(s, "unit", 1) == unit_id} + if kinds and kinds <= {"power"} and part_count > 1: + warnings.append( + f"sub-part {unit_id} carries only power pins, which " + f"is how the source separates the supply rails. It " + f"is emitted as a real sub-part; if you would rather " + f"the rails appeared on every part, set those pins' " + f"owner_part_id to 0 and drop part_count to " + f"{part_count - 1}") + + pins: list[dict[str, Any]] = [] + for s in comp.symbol.shapes: + if s.kind != "pin": + continue + pins.append({ + "designator": s.number, + "name": s.name or s.number, + "x": int(round(s.x)), + "y": int(round(s.y)), + "rotation": _pin_rotation(s.rotation), + "length": int(round(s.length)) or 300, + "electrical_type": _ALTIUM_ELEC.get( + PIN_ELECTRIC.get(s.electric, "undefined"), "passive"), + }) + # A hidden pin is electrically real; only its visibility is + # carried across. Emitting it visible would add every NC and + # supply rail the source deliberately hides. + if not getattr(s, "display", True): + pins[-1]["hidden"] = True + if part_count > 1: + pins[-1]["owner_part_id"] = getattr(s, "unit", 1) + # The inversion bubble hangs off the OUTER edge of the pin and + # the clock wedge off the INNER edge; they are independent, so + # an inverted clock carries both. Dropping either one produces + # a symbol that states the wrong thing rather than an + # incomplete one: a pin drawn without its bubble reads as + # active-high. + if getattr(s, "dot", False): + pins[-1]["symbol_outer_edge"] = "dot" + if getattr(s, "clock", False): + pins[-1]["symbol_inner_edge"] = "clock" + # Label visibility. KiCad declares this once per symbol and + # Altium stores it per pin, so the reader has already pushed + # the flag down onto every pin. Only the False case is sent: + # visible is Altium's default, and saying so explicitly would + # add two fields to every pin of every symbol for no change + # in what gets drawn. + if not getattr(s, "name_visible", True): + pins[-1]["show_name"] = False + if not getattr(s, "number_visible", True): + pins[-1]["show_designator"] = False + # Same filter on the pin side: a pin with no designator is + # discarded by lib_add_pins without failing. + unnamed_pins = [p for p in pins if not p["designator"]] + pins = [p for p in pins if p["designator"]] + if unnamed_pins: + warnings.append( + f"{len(unnamed_pins)} pin(s) carry no pin number and were " + f"NOT emitted; lib_add_pins requires a designator and " + f"silently discards blanks.") + if pins: + steps.append({"tool": "lib_add_pins", "args": {"pins": pins}}) + else: + warnings.append("symbol has no pins") + + if include_body_art: + steps.extend(_symbol_art_steps(comp, schlib_path, sym_name)) + texts = [s for s in comp.symbol.shapes + if s.kind == "text" and s.text] + if texts: + # Altium's primitive for free text on a symbol is an + # ISch_Label, which lib_add_symbol_text now places. These + # used to be dropped with a warning, which cost 2922 + # items across 72 of the installed KiCad libraries: + # polarity marks, pin-group headings and NC annotations, + # i.e. things that change what the symbol SAYS rather + # than how it looks. + items: list[dict[str, Any]] = [] + for t in texts: + entry: dict[str, Any] = { + "text": t.text, + "x": int(round(t.x)), + "y": int(round(t.y)), + "rotation": _pin_rotation(getattr(t, "rotation", 0)), + } + if part_count > 1: + entry["owner_part_id"] = getattr(t, "unit", 1) + items.append(entry) + steps.append({"tool": "lib_add_symbol_text", + "args": {"texts": items}}) + # Height is deliberately NOT sent. The source states it + # in mils and the tool takes Altium's own font size, and + # the relation between the two is not documented + # anywhere this project can check. Reporting the range + # is honest; inventing a factor would silently resize + # every note and look like it had worked. + heights = sorted({int(round(t.font_size)) for t in texts + if getattr(t, "font_size", 0)}) + if heights: + warnings.append( + f"{len(texts)} symbol text item(s) were placed at " + f"the default font size; the source heights " + f"({heights[0]}-{heights[-1]} mils) were not " + f"mapped, because Altium's font size is not in " + f"mils and the conversion is not documented. " + f"Adjust by hand if the size matters.") + + # ---- footprint ---------------------------------------------------- + if comp.footprint is not None: + steps.append({"tool": "app_set_active_document", "args": { + "file_path": pcblib_path, + }}) + steps.append({"tool": "lib_create_footprint", "args": { + "name": fp_name, + "description": comp.description or comp.mpn, + }}) + + pads: list[dict[str, Any]] = [] + unnamed_pads: list[dict[str, Any]] = [] + apertures: list[Any] = [] + for s in comp.footprint.shapes: + if s.kind != "pad": + continue + # A pad on a PASTE or MASK layer is a stencil aperture, not + # copper. Altium's pad primitive is copper by definition, so + # emitting one here would put metal where the source has + # none -- shorting adjacent pads on the fine-pitch parts + # that use paste subdivision. + # + # These are skipped on that ground rather than on the blank + # designator they happen to carry. Every one of the 332 in + # KiCad 10.0.1's sampled libraries is nameless, so the + # existing no-designator guard catches them today, but that + # is a coincidence of the corpus and not a property of an + # aperture. One with a name would have become copper. + if s.layer in (5, 6, 7, 8): + apertures.append(s) + continue + pad: dict[str, Any] = { + "designator": s.number, + "x": int(round(s.cx)), + "y": int(round(s.cy)), + "x_size": int(round(s.width)), + "y_size": int(round(s.height)), + "shape": _ALTIUM_PAD_SHAPE.get(s.shape, "round"), + # A drilled pad is forced through-hole by the tool, so + # only an SMD pad's layer is meaningful. + "layer": "BottomLayer" if s.layer == 2 else "TopLayer", + "hole_size": int(round(s.hole_radius * 2)), + "rotation": float(s.rotation or 0), + } + if pad["shape"] == "roundrect": + pad["corner_radius"] = _corner_radius_pct( + getattr(s, "corner_ratio", 0.0)) + # lib_add_footprint_pads DROPS any pad with a blank + # designator (counted as skipped_invalid, not an error), so + # a numberless pad would vanish with nothing to notice. + if pad["designator"]: + pads.append(pad) + else: + unnamed_pads.append(pad) + # An unplated HOLE (mounting hole, tooling hole) cannot be + # expressed here. Emitting it as a pad does NOT work: + # lib_add_footprint_pads drops any pad whose designator is empty + # (counted as skipped_invalid), so the step would vanish + # silently. Giving it a designator is worse, not better: in + # Altium a designator makes the pad connectable, so a mounting + # hole would show up as a real net-joinable pad. + if unnamed_pads: + spots = ", ".join(f"({p['x']},{p['y']})" + for p in unnamed_pads[:4]) + warnings.append( + f"{len(unnamed_pads)} pad(s) at {spots} carry no pad " + f"number and were NOT emitted; lib_add_footprint_pads " + f"requires a designator and silently discards blanks. " + f"Add them by hand if they are real copper.") + + if apertures: + spots = ", ".join( + f"({int(round(a.cx))},{int(round(a.cy))})" + for a in apertures[:4]) + warnings.append( + f"{len(apertures)} solder-paste / mask APERTURE(s) at " + f"{spots} were NOT emitted. They carry no copper, and " + f"an Altium pad always does, so adding them as pads " + f"would short the pads they subdivide. Draw them as " + f"regions on the paste or mask layer by hand if the " + f"stencil needs them.") + + # A SLOTTED drill becomes a round one here: the pad payload + # carries a single hole_size and has no slot length. The + # resulting hole is the right diameter and the wrong shape, so + # a part with a rectangular lead will not fit, and nothing + # downstream would reveal it. + slots = [s for s in comp.footprint.shapes + if s.kind == "pad" and getattr(s, "is_slot", False)] + if slots: + spots = ", ".join( + f"{s.number or '?'} at ({int(round(s.cx))}," + f"{int(round(s.cy))})" for s in slots[:4]) + warnings.append( + f"{len(slots)} pad(s) have a SLOTTED hole ({spots}) which " + f"was emitted as a ROUND hole of the same width; this " + f"API has no slot length. Edit the hole shape by hand, or " + f"a rectangular lead will not fit.") + + # Same for plating: an unplated pad is emitted as a normal + # plated one, which puts copper in a hole meant to have none. + unplated = [s for s in comp.footprint.shapes + if s.kind == "pad" and s.number + and getattr(s, "is_through_hole", False) + and not getattr(s, "plated", True)] + if unplated: + spots = ", ".join( + f"{s.number} at ({int(round(s.cx))},{int(round(s.cy))})" + for s in unplated[:4]) + warnings.append( + f"{len(unplated)} pad(s) are UNPLATED in the source " + f"({spots}) but were emitted as ordinary plated pads; " + f"this API cannot set plating. Clear the plating by hand " + f"if the hole is meant to be bare.") + + holes = [s for s in comp.footprint.shapes if s.kind == "hole"] + if holes: + spots = ", ".join(f"({int(round(h.cx))},{int(round(h.cy))})" + for h in holes[:4]) + warnings.append( + f"{len(holes)} unplated hole(s) at {spots} were NOT " + f"created; this API has no NPTH primitive (a pad needs a " + f"designator, which would make the hole connectable). " + f"Add them by hand, or the board will not be drilled for " + f"them.") + if pads: + steps.append({"tool": "lib_add_footprint_pads", + "args": {"pads": pads}}) + else: + warnings.append("footprint has no pads") + + steps.extend(_footprint_art_steps( + comp, pcblib_path, fp_name, warnings)) + + # ---- 3D body ------------------------------------------------------ + # Only when the caller resolved the reference to a real STEP file on + # this machine. lib_link_3d_model loads the geometry, so a path that + # does not exist would fail at execution time, and a guessed one + # would attach the wrong shape. This runs BEFORE the schematic-side + # linking below because it needs the .PcbLib still active. + model_path = getattr(comp.footprint, "model_3d_path", "") \ + if comp.footprint is not None else "" + if model_path: + steps.append({"tool": "lib_link_3d_model", "args": { + "component_name": fp_name, + "model_path": model_path, + }}) + elif comp.footprint is not None and getattr( + comp.footprint, "model_3d_ref", ""): + warnings.append( + f"the footprint names a 3D model " + f"({comp.footprint.model_3d_ref}) that was not resolved to a " + f"file here, so no 3D body was linked; pass a resolved STEP " + f"path to lib_link_3d_model by hand") + + # ---- linking ------------------------------------------------------ + if comp.symbol is not None and comp.footprint is not None: + # Linking is a schematic-side edit, so the .SchLib has to be + # active again after the footprint work. + steps.append({"tool": "app_set_active_document", "args": { + "file_path": schlib_path, + }}) + steps.append({"tool": "lib_link_footprint", "args": { + "component_name": sym_name, + "footprint_name": fp_name, + "footprint_library": pcblib_path, + }}) + + if comp.footprint is not None and comp.footprint.model_3d_uuid: + warnings.append( + "a 3D model is referenced by uuid; fetch it separately and " + "attach with lib_link_3d_model (EasyEDA serves OBJ, Altium " + "wants STEP, so a conversion may be required)") + + # Name the text the bridge will flatten. Altium's DelphiScript + # strings are single byte and UnescapeJsonString emits '?' for any + # codepoint above 255, so an LCSC description in Chinese imports as + # question marks with nothing reporting it. + # + # This lives in the shared plan builder rather than in either import + # tool: lib_easyeda_import and lib_kicad_import both call it, and + # putting the scan in one of them is how the two drift apart. + for field, chars in unsendable_in_plan(steps): + warnings.append( + f"{field} contains characters the bridge cannot carry " + f"({chars}); Altium will receive '?' for each of them") + + return { + "ok": True, + "steps": steps, + "warnings": warnings, + "summary": { + "symbol": sym_name or None, + "footprint": fp_name or None, + "pin_count": sum( + len(s["args"]["pins"]) for s in steps + if s["tool"] == "lib_add_pins"), + "pad_count": sum( + len(s["args"]["pads"]) for s in steps + if s["tool"] == "lib_add_footprint_pads"), + "step_count": len(steps), + }, + } + + +def _symbol_art_steps( + comp: EasyEdaComponent, schlib_path: str, sym_name: str, +) -> list[dict[str, Any]]: + # These tools take neither library_path nor component_name: they act + # on the current symbol, which lib_create_symbol has just made + # current. The caller must keep the emitted step ORDER. + steps: list[dict[str, Any]] = [] + + for s in comp.symbol.shapes: + if s.kind == "rect": + steps.append({"tool": "lib_add_symbol_rectangle", "args": { + "x1": int(round(s.x)), "y1": int(round(s.y)), + "x2": int(round(s.x + s.width)), + "y2": int(round(s.y + s.height))}}) + elif s.kind in ("polyline", "polygon") and len(s.points) >= 2: + pts = [(int(round(x)), int(round(y))) for x, y in s.points] + if s.kind == "polygon": + if pts[0] != pts[-1]: + pts.append(pts[0]) + if len(pts) >= 3: + # vertices is a flat comma-separated string, not a + # list of pairs. + steps.append({ + "tool": "lib_add_symbol_polygon", + "args": {"vertices": ",".join( + f"{x},{y}" for x, y in pts)}}) + else: + # Same closure rule as the footprint tracks below. The + # symbol reader happens to keep the repeated vertex, so + # this is currently a no-op there, but the model field + # means the same thing on both sides and honouring it in + # only one of them is how the footprint path came to + # drop its closing edge. + if getattr(s, "closed", False) and len(pts) >= 3 \ + and pts[0] != pts[-1]: + pts.append(pts[0]) + lines = [{"x1": a[0], "y1": a[1], "x2": b[0], "y2": b[1]} + for a, b in zip(pts, pts[1:])] + steps.append({"tool": "lib_add_symbol_lines", + "args": {"lines": lines}}) + elif s.kind in ("circle", "ellipse") and s.radius > 0: + steps.append({"tool": "lib_add_symbol_arc", "args": { + "x_center": int(round(s.cx)), "y_center": int(round(s.cy)), + "radius": int(round(s.radius)), + "start_angle": 0.0, "end_angle": 360.0}}) + elif s.kind == "arc" and getattr(s, "is_valid", False): + # Symbol arcs were silently dropped: the footprint path + # handled them but this one never had a branch, so curved + # symbol art vanished with no warning even though + # lib_add_symbol_arc exists. + arc = svg_arc_to_center(s.x1, s.y1, s.rx, s.ry, s.rotation, + s.large_arc, s.sweep, s.x2, s.y2) + if arc is not None: + steps.append({"tool": "lib_add_symbol_arc", "args": { + "x_center": int(round(arc.cx)), + "y_center": int(round(arc.cy)), + # Altium symbol arcs are circular; an ellipse is + # approximated by its mean radius. + "radius": int(round((arc.rx + arc.ry) / 2.0)), + "start_angle": round(arc.start_angle, 3), + "end_angle": round(arc.end_angle, 3)}}) + return steps + + +def _footprint_art_steps( + comp: EasyEdaComponent, pcblib_path: str, fp_name: str, + warnings: Optional[list[str]] = None, +) -> list[dict[str, Any]]: + # Like the symbol art, these act on the CURRENT footprint. + steps: list[dict[str, Any]] = [] + warned_elliptical: list[str] = [] + + tracks: list[dict[str, Any]] = [] + for s in comp.footprint.shapes: + layer = _ALTIUM_LAYER.get(getattr(s, "layer", 3), "TopOverlay") + if s.kind in ("track", "polyline", "solid_region") \ + and len(s.points) >= 2: + pts = list(s.points) + # A closed shape's last edge runs back to the first point. + # The model stores that closure IMPLICITLY (the repeated + # final vertex is normalised away on read), so walking + # consecutive pairs alone emits every edge except the + # closing one and leaves a notch in an outline that is + # meant to be sealed. + if getattr(s, "closed", False) and len(pts) >= 3 \ + and pts[0] != pts[-1]: + pts.append(pts[0]) + for (x1, y1), (x2, y2) in zip(pts, pts[1:]): + tracks.append({ + "x1": int(round(x1)), "y1": int(round(y1)), + "x2": int(round(x2)), "y2": int(round(y2)), + "width": int(round(s.stroke_width)) or 6, + "layer": layer, + }) + elif s.kind == "rect": + x1, y1 = int(round(s.x)), int(round(s.y)) + x2 = int(round(s.x + s.width)) + y2 = int(round(s.y + s.height)) + w = int(round(s.stroke_width)) or 6 + for a, b in (((x1, y1), (x2, y1)), ((x2, y1), (x2, y2)), + ((x2, y2), (x1, y2)), ((x1, y2), (x1, y1))): + tracks.append({"x1": a[0], "y1": a[1], + "x2": b[0], "y2": b[1], + "width": w, "layer": layer}) + elif s.kind == "circle" and s.radius > 0: + steps.append({"tool": "lib_add_footprint_arc", "args": { + "x_center": int(round(s.cx)), + "y_center": int(round(s.cy)), + "radius": int(round(s.radius)), + "start_angle": 0.0, "end_angle": 360.0, + "width": int(round(s.stroke_width)) or 6, + "layer": layer}}) + elif s.kind == "arc" and s.is_valid: + arc = svg_arc_to_center(s.x1, s.y1, s.rx, s.ry, s.rotation, + s.large_arc, s.sweep, s.x2, s.y2) + if arc is not None: + # Altium arcs are circular; an elliptical source is + # approximated by its mean radius, so say so rather than + # let a squashed outline pass as faithful. + if not arc.is_circular: + warned_elliptical.append(fp_name) + steps.append({"tool": "lib_add_footprint_arc", "args": { + "x_center": int(round(arc.cx)), + "y_center": int(round(arc.cy)), + "radius": int(round((arc.rx + arc.ry) / 2.0)), + "start_angle": round(arc.start_angle, 3), + "end_angle": round(arc.end_angle, 3), + "width": int(round(s.stroke_width)) or 6, + "layer": layer}}) + elif s.kind == "text" and s.text and s.visible: + text_args: dict[str, Any] = { + "x": int(round(s.x)), "y": int(round(s.y)), + "text": s.text, + # the tool calls this "size", not "height" + "size": int(round(s.font_size)) or 60, + "rotation": int(round(s.rotation)) % 360, + "layer": layer} + # Stroke width is what makes text legible at a given height; + # the tool's default of 8 mils is a fixed guess that reads + # heavy under small text and thin under large. Sent only when + # the source states it, so nothing changes for a source that + # does not. + if int(round(getattr(s, "stroke_width", 0) or 0)) > 0: + text_args["width"] = int(round(s.stroke_width)) + # Text on a bottom-side layer has to be mirrored or it reads + # backwards once the board is made. This is not a preference: + # audit_find_mirrored_pcb_text reports unmirrored bottom + # overlay text as a violation, so emitting it plain means + # this importer produces libraries our own audit rejects. + # The layer decides, because it is the physical fact; a + # source flag is honoured only where the layer leaves the + # question open. + if getattr(s, "layer", 3) in _BOTTOM_SIDE_LAYERS: + text_args["mirror"] = True + elif getattr(s, "mirror", False): + text_args["mirror"] = True + steps.append({"tool": "lib_add_footprint_text", + "args": text_args}) + + # Only genuine pours matter here. Real parts carry many + # fill="cutout" regions on undocumented layers (97 on an LQFP-48); + # warning about those would cry wolf on every import. + regions = [s for s in comp.footprint.shapes + if s.kind == "solid_region" and len(s.points) >= 3 + and str(getattr(s, "fill", "") or "").lower() != "cutout" + and getattr(s, "layer", None) in _ALTIUM_LAYER] + if regions and warnings is not None: + # There is no lib_add_footprint_region: the library authoring API + # exposes pads, tracks, arcs and text only (pcb_place_region works + # on a BOARD, not inside a .PcbLib). So the fill cannot be + # reproduced and only its outline is drawn. Say so, because a + # missing copper pour is an electrical difference, not cosmetic. + warnings.append( + f"{len(regions)} filled copper region(s) drawn as an OUTLINE " + f"only; Altium library footprints have no region primitive in " + f"this API. Add the fill by hand, or the pad will be missing " + f"copper.") + + if warned_elliptical and warnings is not None: + warnings.append( + f"{len(warned_elliptical)} elliptical arc(s) approximated by " + f"their mean radius; Altium arcs are circular. Check the " + f"silkscreen against the datasheet outline.") + if tracks: + # One bulk call: lib_add_footprint_tracks exists precisely so a + # silkscreen outline is not N round trips. + steps.insert(0, {"tool": "lib_add_footprint_tracks", + "args": {"tracks": tracks}}) + return steps diff --git a/src/eda_agent/libimport/easyeda/document.py b/src/eda_agent/libimport/easyeda/document.py new file mode 100644 index 0000000..3513a92 --- /dev/null +++ b/src/eda_agent/libimport/easyeda/document.py @@ -0,0 +1,335 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""EasyEDA component document model. + +One level above :mod:`shapes`: takes the JSON an EasyEDA/LCSC component +response carries and produces a normalized :class:`EasyEdaComponent` +with symbol geometry, footprint geometry, and part metadata, all in +MILS relative to each element's own origin and with the Y axis already +flipped to Y-up. + +Why normalize here rather than in each emitter: EasyEDA is Y-down on an +absolute canvas, KiCad symbols are Y-up, KiCad footprints are Y-down, +and Altium is Y-up. Converting once to a single neutral convention +(Y-up, mils, origin-relative) means each emitter applies at most one +further flip, instead of every emitter re-deriving the same arithmetic. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, Optional + +from eda_agent.libimport.easyeda.shapes import ( + EASYEDA_UNIT_MIL, + EeShape, + parse_footprint_shapes, + parse_symbol_shapes, +) + +__all__ = [ + "EasyEdaComponent", + "EasyEdaFootprint", + "EasyEdaSymbol", + "parse_component", +] + + +def _origin(head: dict[str, Any]) -> tuple[float, float]: + """The element's origin in canvas units.""" + try: + return (float(head.get("x", 0) or 0), float(head.get("y", 0) or 0)) + except (TypeError, ValueError): + return (0.0, 0.0) + + +@dataclass +class EasyEdaSymbol: + """Schematic symbol: shapes in mils, Y-up, relative to the origin.""" + + name: str = "" + prefix: str = "U" + shapes: list[EeShape] = field(default_factory=list) + + +@dataclass +class EasyEdaFootprint: + """PCB footprint: shapes in mils, Y-up, relative to the origin.""" + + name: str = "" + shapes: list[EeShape] = field(default_factory=list) + model_3d_uuid: Optional[str] = None + model_3d_name: Optional[str] = None + #: Reference to a 3D model FILE, as the source recorded it. KiCad + #: writes "${KICAD10_3DMODEL_DIR}/Lib.3dshapes/Name.step", which + #: resolves to a real STEP file, and Altium's linker wants STEP. The + #: EasyEDA path has no equivalent: its model arrives as OBJ, which + #: Altium cannot load, so this stays empty there. + model_3d_ref: str = "" + #: The same reference resolved to a path on this machine, when it + #: could be. Blank means it was not found, never a guess. + model_3d_path: str = "" + + +@dataclass +class EasyEdaComponent: + """A whole part: metadata plus its symbol and footprint.""" + + lcsc_id: str = "" + mpn: str = "" + manufacturer: str = "" + package: str = "" + datasheet: str = "" + description: str = "" + #: The footprint the SOURCE says belongs to this symbol, in whatever + #: form it records ("Library:Name" for KiCad). Distinct from + #: ``package``, which is a human package name: this one is a pointer + #: that can be resolved to a real file, and resolving it is what + #: turns a symbol-only hit into a whole part. + footprint_ref: str = "" + #: How many sub-parts the SOURCE part has. A quad gate reads as 4. + #: Every unit is normally read at once and each pin tagged via + #: ``EePin.unit``, so the emitter builds ONE multi-part component; + #: this is here so a caller can see the shape of the part without + #: walking the pins. Carried as data rather than only as a warning + #: string, since acting on it should not mean parsing prose. + unit_count: int = 1 + #: The single sub-part this component holds, when only one was + #: requested. Meaningless when all units were read (the pins carry + #: their own), and left at 1 by sources with no sub-part concept. + unit: int = 1 + symbol: Optional[EasyEdaSymbol] = None + footprint: Optional[EasyEdaFootprint] = None + warnings: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "lcsc_id": self.lcsc_id, + "mpn": self.mpn, + "manufacturer": self.manufacturer, + "package": self.package, + "datasheet": self.datasheet, + "description": self.description, + "footprint_ref": self.footprint_ref, + "unit_count": self.unit_count, + "unit": self.unit, + "symbol": { + "name": self.symbol.name, + "prefix": self.symbol.prefix, + "shape_count": len(self.symbol.shapes), + "pin_count": sum( + 1 for s in self.symbol.shapes if s.kind == "pin"), + } if self.symbol else None, + "footprint": { + "name": self.footprint.name, + "shape_count": len(self.footprint.shapes), + "pad_count": sum( + 1 for s in self.footprint.shapes if s.kind == "pad"), + "model_3d_uuid": self.footprint.model_3d_uuid, + } if self.footprint else None, + "warnings": list(self.warnings), + } + + +def _to_mils_yup(shapes: list[EeShape], ox: float, oy: float) -> None: + """Convert every shape in place: units -> mils, canvas -> origin, Y up. + + EasyEDA's canvas grows downward, so a Y coordinate becomes + ``(origin_y - value) * unit``; X is a plain ``(value - origin_x)``. + """ + k = EASYEDA_UNIT_MIL + + def cx(v: float) -> float: + return (v - ox) * k + + def cy(v: float) -> float: + return (oy - v) * k + + for s in shapes: + kind = s.kind + if kind == "pad": + s.cx, s.cy = cx(s.cx), cy(s.cy) + s.width *= k + s.height *= k + # EasyEDA stores a hole RADIUS; keep a diameter downstream. + s.hole_radius *= k + s.hole_length *= k + s.points = [(cx(px), cy(py)) for (px, py) in s.points] + # A Y mirror negates rotation. Rect/oval pads happen to be + # 180-symmetric so this is invisible for them, but relying on + # that would break the moment a non-symmetric pad shows up. + s.rotation = (-s.rotation) % 360.0 + elif kind == "pin": + s.x, s.y = cx(s.x), cy(s.y) + s.length *= k + # Two corrections collapse into one formula. + # + # 1. EasyEDA's rotation is 180 degrees off the KiCad/Altium + # convention. Verified against a real API payload: a body + # spanning x=370..430 has its LEFT pins at x=360 drawn + # inward (M360,310h10) carrying rot=180, and its RIGHT + # pins at x=440 drawn inward carrying rot=0. KiCad and + # Altium both call "extends +X" angle 0. + # 2. Flipping Y mirrors direction, negating the angle. + # + # canvas direction d = (-cos t, -sin t) [Y-down] + # neutral direction d = (-cos t, +sin t) [Y-up] + # = (cos(180 - t), sin(180 - t)) + s.rotation = (180.0 - s.rotation) % 360.0 + elif kind in ("rect",): + # Rect y is the TOP edge on a Y-down canvas; after flipping + # it becomes the BOTTOM edge, which is what Y-up consumers + # expect from (x, y, w, h). + s.x = cx(s.x) + s.y = cy(s.y + s.height) + s.width *= k + s.height *= k + s.stroke_width *= k + elif kind in ("circle", "ellipse"): + s.cx, s.cy = cx(s.cx), cy(s.cy) + s.radius *= k + if s.ry is not None: + s.ry *= k + s.stroke_width *= k + elif kind in ("polyline", "polygon", "track", "solid_region"): + s.points = [(cx(px), cy(py)) for (px, py) in s.points] + s.stroke_width *= k + elif kind == "arc": + s.stroke_width *= k + s.x1, s.y1 = cx(s.x1), cy(s.y1) + s.x2, s.y2 = cx(s.x2), cy(s.y2) + # Radii are lengths: they scale, but are never translated + # or flipped. Flipping Y mirrors the curve, which reverses + # the sweep direction. + s.rx *= k + s.ry *= k + s.sweep = 0 if s.sweep else 1 + elif kind == "text": + s.x, s.y = cx(s.x), cy(s.y) + s.font_size *= k + s.stroke_width *= k + s.rotation = (-s.rotation) % 360.0 + elif kind == "hole": + s.cx, s.cy = cx(s.cx), cy(s.cy) + s.diameter *= k + + +def _attr(attrs: dict[str, Any], *names: str, default: str = "") -> str: + for n in names: + v = attrs.get(n) + if v: + return str(v).strip() + return default + + +def parse_component(payload: dict[str, Any]) -> EasyEdaComponent: + """Build a component from an EasyEDA/LCSC component JSON payload. + + Accepts either the raw API envelope (``{"success":..,"result":{..}}``) + or the inner result object, so a saved fixture works either way. + """ + result = payload.get("result", payload) or {} + comp = EasyEdaComponent() + + comp.lcsc_id = str( + result.get("szlcsc", {}).get("code") + or result.get("code") or "").strip() + + data_str = result.get("dataStr") or {} + head = data_str.get("head") or {} + attrs = head.get("c_para") or {} + + comp.mpn = _attr(attrs, "Manufacturer Part", "name") + comp.manufacturer = _attr(attrs, "Manufacturer") + comp.package = _attr(attrs, "package", "Package") + comp.datasheet = str( + result.get("lcsc", {}).get("url") + or result.get("szlcsc", {}).get("url") or "").strip() + comp.description = str(result.get("description") or "").strip() + + # ---- symbol ------------------------------------------------------- + sym_shapes_raw = data_str.get("shape") or [] + if isinstance(sym_shapes_raw, list): + blob = "#@$".join(str(s) for s in sym_shapes_raw) + else: + blob = str(sym_shapes_raw) + if blob.strip(): + sym = EasyEdaSymbol( + name=comp.mpn or comp.lcsc_id or "SYMBOL", + prefix=(_attr(attrs, "pre", "Prefix", default="U?") + .replace("?", "") or "U"), + shapes=parse_symbol_shapes(blob), + ) + ox, oy = _origin(head) + _to_mils_yup(sym.shapes, ox, oy) + comp.symbol = sym + + # ---- footprint ---------------------------------------------------- + pkg = result.get("packageDetail") or {} + pkg_data = pkg.get("dataStr") or {} + pkg_head = pkg_data.get("head") or {} + fp_shapes_raw = pkg_data.get("shape") or [] + if isinstance(fp_shapes_raw, list): + fp_blob = "#@$".join(str(s) for s in fp_shapes_raw) + else: + fp_blob = str(fp_shapes_raw) + + if fp_blob.strip(): + fp = EasyEdaFootprint( + name=(pkg.get("title") or comp.package or "FOOTPRINT").strip(), + shapes=parse_footprint_shapes(fp_blob), + ) + pox, poy = _origin(pkg_head) + _to_mils_yup(fp.shapes, pox, poy) + _attach_3d(fp, pkg_data) + comp.footprint = fp + + if comp.symbol is None: + comp.warnings.append("payload carries no symbol geometry") + if comp.footprint is None: + comp.warnings.append("payload carries no footprint geometry") + _warn_unsupported(comp) + return comp + + +def _attach_3d(fp: EasyEdaFootprint, pkg_data: dict[str, Any]) -> None: + """Find the 3D model reference, which rides as an SVGNODE shape.""" + for raw in pkg_data.get("shape") or []: + s = str(raw) + if not s.startswith("SVGNODE"): + continue + try: + node = json.loads(s.split("~", 1)[1]) + except (ValueError, IndexError): + continue + attrs = node.get("attrs") or {} + uuid = attrs.get("uuid") + if uuid: + fp.model_3d_uuid = str(uuid) + fp.model_3d_name = str(attrs.get("title") or "").strip() or None + return + + +def _warn_unsupported(comp: EasyEdaComponent) -> None: + """Flag geometry no target CAD can reproduce faithfully. + + Silence here would be the dangerous outcome: a polygon pad quietly + approximated by a rectangle changes the land pattern. + """ + if comp.footprint: + polys = [s for s in comp.footprint.shapes + if s.kind == "pad" and s.shape == "POLYGON"] + if polys: + nums = ", ".join(sorted(p.number for p in polys if p.number)) + comp.warnings.append( + f"{len(polys)} polygon pad(s) ({nums}) have no native " + f"equivalent in KiCad or Altium; they are emitted as their " + f"bounding rectangle. Verify against the datasheet land " + f"pattern before use.") + slots = [s for s in comp.footprint.shapes + if s.kind == "pad" and s.is_slot] + if slots: + comp.warnings.append( + f"{len(slots)} slotted hole(s) emitted with approximate " + f"slot geometry; verify drill sizes.") diff --git a/src/eda_agent/libimport/easyeda/fetch.py b/src/eda_agent/libimport/easyeda/fetch.py new file mode 100644 index 0000000..74fe521 --- /dev/null +++ b/src/eda_agent/libimport/easyeda/fetch.py @@ -0,0 +1,221 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Online fetch for EasyEDA / LCSC component data. + +Stdlib only (``urllib``), no new dependency. Hardened the same way the +CSE zip import is: HTTPS only, host allowlist, response size cap, and a +timeout, so a hostile or broken endpoint cannot hang a design session or +write somewhere it should not. + +Endpoints are overridable through the environment because they are a +vendor implementation detail that has moved before: + +* ``EASYEDA_API_BASE`` component/search base (default easyeda.com) +* ``EASYEDA_MODEL_BASE`` 3D model host (default modules.easyeda.com) +* ``EASYEDA_EXTRA_HOSTS`` comma list added to the allowlist + +Nothing here is imported by the offline parsing path, so the converter +still works fully offline from a saved JSON payload. +""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +__all__ = [ + "EasyEdaFetchError", + "fetch_component_json", + "fetch_3d_model", + "search_components", +] + +_DEFAULT_API_BASE = "https://easyeda.com" +_DEFAULT_MODEL_BASE = "https://modules.easyeda.com" + +#: Cap on any single response. Component JSON is tens of KB; a 3D model +#: is the large case and 50 MB is far beyond a legitimate one. +_MAX_BYTES = 50 * 1024 * 1024 +_TIMEOUT_S = 30 + +_USER_AGENT = "eda-agent/0.4 (+https://github.com/salitronic/eda-agent)" + + +class EasyEdaFetchError(RuntimeError): + """Any network / protocol failure, with a caller-friendly message.""" + + +def _api_base() -> str: + return os.environ.get("EASYEDA_API_BASE", _DEFAULT_API_BASE).rstrip("/") + + +def _model_base() -> str: + return os.environ.get("EASYEDA_MODEL_BASE", _DEFAULT_MODEL_BASE).rstrip("/") + + +def _allowed_hosts() -> set[str]: + hosts = set() + for base in (_api_base(), _model_base()): + host = urllib.parse.urlsplit(base).hostname + if host: + hosts.add(host.lower()) + extra = os.environ.get("EASYEDA_EXTRA_HOSTS", "") + for h in extra.split(","): + h = h.strip().lower() + if h: + hosts.add(h) + return hosts + + +def _check_url(url: str) -> None: + parts = urllib.parse.urlsplit(url) + if parts.scheme != "https": + raise EasyEdaFetchError( + f"refusing non-HTTPS URL: {url!r}") + host = (parts.hostname or "").lower() + allowed = _allowed_hosts() + ok = any(host == a or host.endswith("." + a) for a in allowed) + if not ok: + raise EasyEdaFetchError( + f"host {host!r} is not in the allowlist {sorted(allowed)}; " + f"set EASYEDA_EXTRA_HOSTS to permit it") + + +def _get(url: str) -> bytes: + _check_url(url) + req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT}) + try: + with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as resp: + data = resp.read(_MAX_BYTES + 1) + except urllib.error.HTTPError as exc: + raise EasyEdaFetchError( + f"HTTP {exc.code} from {url}: {exc.reason}") from exc + except urllib.error.URLError as exc: + raise EasyEdaFetchError(f"cannot reach {url}: {exc.reason}") from exc + except OSError as exc: + raise EasyEdaFetchError(f"network error for {url}: {exc}") from exc + if len(data) > _MAX_BYTES: + raise EasyEdaFetchError( + f"response from {url} exceeds the {_MAX_BYTES} byte cap") + return data + + +def _get_json(url: str) -> dict[str, Any]: + raw = _get(url) + try: + payload = json.loads(raw.decode("utf-8", errors="replace")) + except ValueError as exc: + raise EasyEdaFetchError(f"{url} did not return JSON: {exc}") from exc + if not isinstance(payload, dict): + raise EasyEdaFetchError(f"{url} returned {type(payload).__name__}, " + f"expected a JSON object") + return payload + + +def _normalize_lcsc(lcsc_id: str) -> str: + """Accept ``C12345``, ``c12345`` or a bare number.""" + s = str(lcsc_id).strip().upper() + if not s: + raise EasyEdaFetchError("empty LCSC id") + if not s.startswith("C"): + s = "C" + s + if not s[1:].isdigit(): + raise EasyEdaFetchError( + f"{lcsc_id!r} is not an LCSC id (expected C followed by digits)") + return s + + +def fetch_component_json(lcsc_id: str) -> dict[str, Any]: + """Raw component payload for an LCSC part number. + + Returns the JSON as served. Feed it to + ``document.parse_component``; keeping fetch and parse separate is + what lets the same payload be saved as a test fixture. + """ + code = _normalize_lcsc(lcsc_id) + url = f"{_api_base()}/api/products/{urllib.parse.quote(code)}/components" + payload = _get_json(url) + if not payload.get("success", True): + raise EasyEdaFetchError( + f"{code}: upstream reported failure " + f"({payload.get('message') or 'no message'})") + if not payload.get("result"): + raise EasyEdaFetchError(f"{code}: no component data in the response") + return payload + + +def search_components(query: str, limit: int = 20) -> list[dict[str, Any]]: + """Search LCSC/EasyEDA for parts matching ``query``. + + Returns a trimmed list of ``{lcsc_id, mpn, manufacturer, package, + description}`` so a caller can pick before fetching the full payload. + The upstream search response shape is not contractual, so every field + is read defensively and a shape change degrades to blanks rather than + an exception. + """ + q = urllib.parse.quote(str(query).strip()) + if not q: + raise EasyEdaFetchError("empty search query") + url = f"{_api_base()}/api/products/search?wd={q}&limit={int(limit)}" + try: + payload = _get_json(url) + except EasyEdaFetchError as exc: + # Verified against the live service: this endpoint now answers + # 404 (403 with a browser user-agent), and LCSC's own + # wmsc global-search returns HTTP 200 carrying + # {"code": 404, "ok": false, "msg": "static resource ..."}. + # Neither is usable unauthenticated, so say so plainly instead + # of surfacing a bare HTTP error the caller cannot act on. + raise EasyEdaFetchError( + "Part search has no usable machine endpoint (upstream said: " + f"{exc}). This is not a credentials problem and logging in " + "will not fix it: the route answers with an HTML error page " + "and no auth challenge, LCSC's own search API returns an " + "error body, and the LCSC results page is rendered " + "client-side, so there is nothing to fetch or authenticate " + "against. Search LCSC in a browser to get the part number, " + "then import by id, which is unaffected and reliable: " + "lib_easyeda_import(lcsc_id=\"C1234\", ...)." + ) from exc + + result = payload.get("result") or {} + rows = result.get("productList") or result.get("list") or [] + if not isinstance(rows, list): + return [] + + out: list[dict[str, Any]] = [] + for row in rows[: int(limit)]: + if not isinstance(row, dict): + continue + attrs = row.get("dataStr", {}).get("head", {}).get("c_para", {}) \ + if isinstance(row.get("dataStr"), dict) else {} + out.append({ + "lcsc_id": str(row.get("number") + or row.get("code") + or row.get("productCode") or "").strip(), + "mpn": str(row.get("title") + or attrs.get("Manufacturer Part") or "").strip(), + "manufacturer": str(row.get("manufacturer") + or attrs.get("Manufacturer") or "").strip(), + "package": str(row.get("package") + or attrs.get("package") or "").strip(), + "description": str(row.get("description") or "").strip(), + }) + return out + + +def fetch_3d_model(uuid: str) -> bytes: + """Raw 3D model bytes for a footprint's model uuid. + + EasyEDA serves an OBJ-family payload here; Altium wants STEP, so the + caller may need a conversion step. Returned as bytes so this module + never decides where a file lands. + """ + u = str(uuid).strip() + if not u: + raise EasyEdaFetchError("empty 3D model uuid") + return _get(f"{_model_base()}/3dmodel/{urllib.parse.quote(u)}") diff --git a/src/eda_agent/libimport/easyeda/geometry.py b/src/eda_agent/libimport/easyeda/geometry.py new file mode 100644 index 0000000..35d4b19 --- /dev/null +++ b/src/eda_agent/libimport/easyeda/geometry.py @@ -0,0 +1,161 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""SVG arc geometry for the EasyEDA converter. + +EasyEDA stores arcs as SVG path data ("M x1,y1 A rx,ry rot large sweep +x2,y2"), an ENDPOINT parameterization. Both target CADs want a CENTRE +parameterization instead: KiCad takes three points on the curve, Altium +takes centre plus start and end angles. Converting between the two is +the standard endpoint-to-centre algorithm from the SVG 1.1 +specification, appendix F.6.5, implemented here rather than approximated. + +Approximating an arc by its chord (the cheap alternative) visibly +deforms pin-1 markers and package outlines, and silently dropping arcs +loses silkscreen entirely, so the real conversion is worth the ~60 lines. +""" + +from __future__ import annotations + +import math +import re +from typing import NamedTuple, Optional + +__all__ = ["ArcGeometry", "parse_svg_arc", "svg_arc_to_center"] + + +class ArcGeometry(NamedTuple): + """An arc in centre form. + + Angles are in DEGREES, measured counter-clockwise from +X, in the + coordinate frame the input points were given in. + """ + + cx: float + cy: float + rx: float + ry: float + start_angle: float + end_angle: float + x1: float + y1: float + x2: float + y2: float + + @property + def sweep_deg(self) -> float: + return self.end_angle - self.start_angle + + def point_at(self, t: float) -> tuple[float, float]: + """A point on the arc, ``t`` in 0..1 from start to end.""" + a = math.radians(self.start_angle + self.sweep_deg * t) + return (self.cx + self.rx * math.cos(a), + self.cy + self.ry * math.sin(a)) + + @property + def midpoint(self) -> tuple[float, float]: + return self.point_at(0.5) + + @property + def is_circular(self) -> bool: + """True when rx == ry within tolerance. + + Neither KiCad's fp_arc nor Altium's arc primitive represents a + true ellipse, so a caller must know when it is about to lose + fidelity. + """ + return abs(self.rx - self.ry) <= max(1e-6, 1e-3 * max(self.rx, self.ry)) + + +_NUM = r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?" +_ARC_RE = re.compile( + rf"M\s*({_NUM})[,\s]+({_NUM})\s*" + rf"A\s*({_NUM})[,\s]+({_NUM})[,\s]+({_NUM})[,\s]+" + rf"([01])[,\s]*([01])[,\s]+({_NUM})[,\s]+({_NUM})", + re.IGNORECASE, +) + + +def parse_svg_arc(path: str) -> Optional[ArcGeometry]: + """Parse an EasyEDA arc path into centre form, or None if it is not + a single ``M ... A ...`` arc.""" + if not path: + return None + m = _ARC_RE.search(path) + if not m: + return None + x1, y1, rx, ry, rot, large, sweep, x2, y2 = ( + float(m.group(1)), float(m.group(2)), + float(m.group(3)), float(m.group(4)), float(m.group(5)), + int(m.group(6)), int(m.group(7)), + float(m.group(8)), float(m.group(9)), + ) + return svg_arc_to_center(x1, y1, rx, ry, rot, large, sweep, x2, y2) + + +def svg_arc_to_center( + x1: float, y1: float, + rx: float, ry: float, phi_deg: float, + large_arc: int, sweep: int, + x2: float, y2: float, +) -> Optional[ArcGeometry]: + """Endpoint to centre parameterization, per SVG 1.1 F.6.5. + + Returns None for a degenerate arc (zero radius, or coincident + endpoints), which the caller should treat as "not an arc" rather + than emitting something malformed. + """ + rx, ry = abs(rx), abs(ry) + if rx == 0 or ry == 0: + return None + if math.isclose(x1, x2, abs_tol=1e-9) and math.isclose(y1, y2, abs_tol=1e-9): + return None + + phi = math.radians(phi_deg) + cos_p, sin_p = math.cos(phi), math.sin(phi) + + # F.6.5.1 compute (x1', y1') + dx2, dy2 = (x1 - x2) / 2.0, (y1 - y2) / 2.0 + x1p = cos_p * dx2 + sin_p * dy2 + y1p = -sin_p * dx2 + cos_p * dy2 + + # F.6.6.2 scale the radii up if they cannot span the endpoints. + lam = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry) + if lam > 1: + s = math.sqrt(lam) + rx *= s + ry *= s + + # F.6.5.2 compute (cx', cy') + num = (rx * rx) * (ry * ry) - (rx * rx) * (y1p * y1p) \ + - (ry * ry) * (x1p * x1p) + den = (rx * rx) * (y1p * y1p) + (ry * ry) * (x1p * x1p) + if den == 0: + return None + factor = math.sqrt(max(0.0, num / den)) + if large_arc == sweep: + factor = -factor + cxp = factor * (rx * y1p / ry) + cyp = factor * (-ry * x1p / rx) + + # F.6.5.3 compute (cx, cy) + cx = cos_p * cxp - sin_p * cyp + (x1 + x2) / 2.0 + cy = sin_p * cxp + cos_p * cyp + (y1 + y2) / 2.0 + + # F.6.5.5 / F.6.5.6 start angle and sweep + def angle_of(px: float, py: float) -> float: + return math.degrees(math.atan2((py - cyp) / ry, (px - cxp) / rx)) + + theta1 = angle_of(x1p, y1p) + theta2 = angle_of(-x1p, -y1p) + delta = theta2 - theta1 + + if sweep == 0 and delta > 0: + delta -= 360.0 + elif sweep == 1 and delta < 0: + delta += 360.0 + + return ArcGeometry( + cx=cx, cy=cy, rx=rx, ry=ry, + start_angle=theta1, end_angle=theta1 + delta, + x1=x1, y1=y1, x2=x2, y2=y2, + ) diff --git a/src/eda_agent/libimport/easyeda/kicad.py b/src/eda_agent/libimport/easyeda/kicad.py new file mode 100644 index 0000000..125b455 --- /dev/null +++ b/src/eda_agent/libimport/easyeda/kicad.py @@ -0,0 +1,545 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Emit KiCad 6+ library files from a normalized EasyEDA component. + +Written against KiCad's own documented s-expression library formats. +Input is the neutral model from :mod:`document` (mils, Y-up, origin +relative), so this module only has to convert units and, for footprints, +flip Y back down (``.kicad_mod`` is Y-down while ``.kicad_sym`` is Y-up). + +Both writers return text, nothing touches the filesystem here, so the +whole path is unit-testable offline. +""" + +from __future__ import annotations + +from typing import Optional + +from eda_agent.libimport.easyeda.document import ( + EasyEdaComponent, + EasyEdaFootprint, + EasyEdaSymbol, +) +from eda_agent.libimport.easyeda.geometry import svg_arc_to_center +from eda_agent.libimport.easyeda.shapes import PIN_ELECTRIC + +__all__ = ["footprint_to_kicad_mod", "symbol_to_kicad_sym"] + +_MIL_TO_MM = 0.0254 + +#: EasyEDA electrical code -> KiCad pin electrical type. +_KICAD_ELEC = { + "undefined": "unspecified", + "input": "input", + "output": "output", + "bidirectional": "bidirectional", + "power": "power_in", + # The reverse of the reader's mapping, so an ERC-relevant pin kind + # survives a trip out to KiCad and back. + "open_collector": "open_collector", + "open_emitter": "open_emitter", + "hiz": "tri_state", +} + +#: EasyEDA layer id -> KiCad layer name. +_KICAD_LAYER = { + 1: "F.Cu", 2: "B.Cu", 3: "F.SilkS", 4: "B.SilkS", + 5: "F.Paste", 6: "B.Paste", 7: "F.Mask", 8: "B.Mask", + # 11 is EasyEDA's MultiLayer (all copper). KiCad has no all-copper + # GRAPHIC layer, so a graphic there falls back to F.Cu; multi-layer + # PADS are handled properly by is_through_hole, which emits "*.Cu". + 10: "Edge.Cuts", 11: "F.Cu", 12: "Cmts.User", + 13: "F.Fab", 14: "B.Fab", +} + +# Observed on real parts and NOT in the documented map: 99, 100, 101. +# Measured across an RS-485 transceiver, an MCU and an 0603 capacitor: +# layer 99/100 -- every SOLIDREGION in all three parts (117 of them, +# both "solid" and "cutout" fills). None sits on a +# copper layer, so none is a pour; _is_real_pour skips +# them and the rendered footprints match the source. +# layer 101 -- exactly one circle per part, which renders as the +# pin-1 marker, so the F.SilkS fallback is right here. +# Unmapped GRAPHICS therefore fall back to silkscreen deliberately; +# unmapped REGIONS are dropped, because painting them was measurably +# worse (see _is_real_pour). + + +def _is_real_pour(shape) -> bool: + """True only for a region that genuinely adds copper. + + Real EasyEDA footprints are full of SOLIDREGION entries that are NOT + pours: an LQFP-48 carries 97 of them, every one ``fill="cutout"`` on + layers 99/100, which are not in the documented layer map. They mark + material to REMOVE, per-pad. + + Emitting those as filled polygons put a solid block over the whole + body and a fill over every pad, i.e. worse than the hairline outlines + the fill support replaced. So require both an explicit solid fill and + a layer we actually understand. + """ + if str(getattr(shape, "fill", "") or "").lower() == "cutout": + return False + return getattr(shape, "layer", None) in _KICAD_LAYER + + +def _mm(mils: float) -> float: + v = round(mils * _MIL_TO_MM, 6) + # Normalize negative zero: "-0.0" is valid but reads as a defect in + # a diffed library file. + return 0.0 if v == 0 else v + + +def _esc(text: str) -> str: + return str(text).replace("\\", "\\\\").replace('"', '\\"') + + +def _shape_extent(s) -> Optional[tuple[float, float, float, float]]: + """(x1, y1, x2, y2) of one shape in the neutral frame, or None.""" + k = s.kind + if k == "pad": + return (s.cx - s.width / 2, s.cy - s.height / 2, + s.cx + s.width / 2, s.cy + s.height / 2) + if k == "pin": + # Span the whole pin, tip to body root, so a label never lands + # on top of a pin line. + import math + a = math.radians(s.rotation) + ex, ey = s.x + s.length * math.cos(a), s.y + s.length * math.sin(a) + return (min(s.x, ex), min(s.y, ey), max(s.x, ex), max(s.y, ey)) + if k == "rect": + return (s.x, s.y, s.x + s.width, s.y + s.height) + if k in ("circle", "ellipse"): + ry = s.ry if getattr(s, "ry", None) else s.radius + return (s.cx - s.radius, s.cy - ry, s.cx + s.radius, s.cy + ry) + if k == "hole": + r = s.diameter / 2 + return (s.cx - r, s.cy - r, s.cx + r, s.cy + r) + if k in ("polyline", "polygon", "track", "solid_region") and s.points: + xs = [x for x, _ in s.points] + ys = [y for _, y in s.points] + return (min(xs), min(ys), max(xs), max(ys)) + if k == "arc" and getattr(s, "is_valid", False): + # Endpoint hull understates a bulging arc, but it is a safe + # lower bound for text placement and needs no trig. + return (min(s.x1, s.x2), min(s.y1, s.y2), + max(s.x1, s.x2), max(s.y1, s.y2)) + return None + + +def _bbox(shapes) -> tuple[float, float, float, float]: + """Bounding box over every shape that has one, else a unit box.""" + boxes = [b for b in (_shape_extent(s) for s in shapes) if b] + if not boxes: + return (-100.0, -100.0, 100.0, 100.0) + return (min(b[0] for b in boxes), min(b[1] for b in boxes), + max(b[2] for b in boxes), max(b[3] for b in boxes)) + + +#: Clear of the body by one text height, so nothing ever overlaps. +_TEXT_GAP_MILS = 60.0 + + +def _pin_lines(pins) -> list[str]: + """The ``(pin ...)`` blocks for one sub-symbol. + + Shared by the unit-0 block and every numbered unit so the two cannot + diverge: the graphic style, the visibility flag and the empty-name + spelling were each a defect once, and having them written twice is + how the next one gets fixed in only one place. + """ + out: list[str] = [] + for s in pins: + elec = _KICAD_ELEC.get(PIN_ELECTRIC.get(s.electric, "undefined"), + "unspecified") + # Already in the KiCad/Altium convention: _to_mils_yup undid + # EasyEDA's 180-degree offset and the Y-mirror negation. + angle = int(round(s.rotation)) % 360 + # KiCad's grammar is `(pin ...)` + # with exactly ONE style token. This emitted "line inverted" -- + # two tokens -- so an inverted pin produced a malformed file + # that KiCad refused with "Unable to load library", while a + # reader taking the second token merely saw a plain "line". + if s.dot and s.clock: + style = "inverted_clock" + elif s.dot: + style = "inverted" + elif s.clock: + style = "clock" + else: + style = "line" + out.append( + f' (pin {elec} {style} ' + f'(at {_mm(s.x)} {_mm(s.y)} {angle}) (length {_mm(s.length)})') + if not getattr(s, "display", True): + out.append(' (hide yes)') + # An empty name is written EMPTY, which is what KiCad 10 itself + # writes. The "~" spelling is the legacy marker and no longer + # appears in its libraries at all, so emitting it produced files + # unlike anything KiCad generates. + out.append(f' (name "{_esc(s.name)}" ' + f'(effects (font (size 1.27 1.27))))') + out.append(f' (number "{_esc(s.number)}" ' + f'(effects (font (size 1.27 1.27))))') + out.append(' )') + return out + + +def symbol_to_kicad_sym( + comp: EasyEdaComponent, lib_name: str = "easyeda", +) -> str: + """A complete ``.kicad_sym`` document holding this one symbol.""" + sym: Optional[EasyEdaSymbol] = comp.symbol + if sym is None: + raise ValueError("component has no symbol geometry") + + name = _esc(sym.name or comp.mpn or "SYMBOL") + # Reference above the body and Value below it, the KiCad library + # convention. Leaving both at (0, 0) stacks them on each other and + # on the pin names in the middle of the symbol. + bx1, by1, bx2, by2 = _bbox(sym.shapes) + mid_x = (bx1 + bx2) / 2.0 + ref_y = by2 + _TEXT_GAP_MILS + val_y = by1 - _TEXT_GAP_MILS + + out: list[str] = [] + # Declare the format we actually WRITE. This emitted 20211014 (the + # 2021 format) while using 2024 syntax such as "(hide yes)", so the + # file contradicted its own header -- tolerated by KiCad, which + # silently migrated it on open, and wrong in the same way the + # two-token pin style was. + # + # Verified against KiCad 10.0.1: loads with no errors and `sym + # upgrade` reports "not updated", i.e. already current. Older KiCad + # may need `kicad-cli sym upgrade` on the result; that is the cost + # of a self-consistent file and the project targets KiCad 9+. + out.append('(kicad_symbol_lib (version 20251024) (generator eda_agent)') + out.append(f' (symbol "{name}" (in_bom yes) (on_board yes)') + # KiCad declares label visibility once per symbol; the neutral model + # and Altium both carry it per pin. Emit the hide only when EVERY pin + # agrees, because a per-symbol flag cannot express a symbol that + # hides some pin names and shows others. A partial disagreement is + # reported rather than half-applied, since silently showing labels + # the source hid is what makes an imported passive look wrong. + _sym_pins = [s for s in sym.shapes if s.kind == "pin"] + for _attr, _tag in (("name_visible", "pin_names"), + ("number_visible", "pin_numbers")): + _flags = {bool(getattr(p, _attr, True)) for p in _sym_pins} + if _flags == {False}: + out.append(f' ({_tag} (hide yes))') + elif len(_flags) > 1: + comp.warnings.append( + f"symbol {name!r} mixes {_attr} across its pins; " + f"KiCad declares it once per symbol, so the labels were " + f"left visible") + out.append(f' (property "Reference" "{_esc(sym.prefix)}" (id 0) ' + f'(at {_mm(mid_x)} {_mm(ref_y)} 0) ' + f'(effects (font (size 1.27 1.27))))') + out.append(f' (property "Value" "{_esc(comp.mpn or name)}" (id 1) ' + f'(at {_mm(mid_x)} {_mm(val_y)} 0) ' + f'(effects (font (size 1.27 1.27))))') + out.append(f' (property "Footprint" "{_esc(comp.package)}" (id 2) ' + f'(at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))') + out.append(f' (property "Datasheet" "{_esc(comp.datasheet)}" (id 3) ' + f'(at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))') + if comp.manufacturer: + out.append(f' (property "Manufacturer" ' + f'"{_esc(comp.manufacturer)}" (id 4) (at 0 0 0) ' + f'(effects (font (size 1.27 1.27)) (hide yes)))') + if comp.lcsc_id: + out.append(f' (property "LCSC" "{_esc(comp.lcsc_id)}" (id 5) ' + f'(at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))') + + out.append(f' (symbol "{name}_0_1"') + for s in sym.shapes: + if s.kind == "rect": + out.append( + f' (rectangle (start {_mm(s.x)} {_mm(s.y)}) ' + f'(end {_mm(s.x + s.width)} {_mm(s.y + s.height)}) ' + f'(stroke (width 0) (type default)) ' + f'(fill (type {"background" if s.fill else "none"})))') + elif s.kind in ("circle", "ellipse"): + out.append( + f' (circle (center {_mm(s.cx)} {_mm(s.cy)}) ' + f'(radius {_mm(s.radius)}) ' + f'(stroke (width 0) (type default)) (fill (type none)))') + elif s.kind in ("polyline", "polygon") and len(s.points) >= 2: + pts = list(s.points) + if s.kind == "polygon" and pts[0] != pts[-1]: + pts.append(pts[0]) + joined = " ".join(f"(xy {_mm(x)} {_mm(y)})" for x, y in pts) + out.append( + f' (polyline (pts {joined}) ' + f'(stroke (width 0) (type default)) ' + f'(fill (type {"background" if s.fill else "none"})))') + elif s.kind == "arc" and s.is_valid: + arc = svg_arc_to_center(s.x1, s.y1, s.rx, s.ry, s.rotation, + s.large_arc, s.sweep, s.x2, s.y2) + if arc is not None: + mx, my = arc.midpoint + out.append( + f' (arc (start {_mm(arc.x1)} {_mm(arc.y1)}) ' + f'(mid {_mm(mx)} {_mm(my)}) ' + f'(end {_mm(arc.x2)} {_mm(arc.y2)}) ' + f'(stroke (width 0) (type default)) ' + f'(fill (type none)))') + elif s.kind == "text" and s.text: + # Back to DECIDEGREES, which is what .kicad_sym text uses + # (see the reader). This wrote a literal 0, so every rotated + # string in a symbol body came out upright. + ang10 = int(round(s.rotation * 10)) % 3600 + size_mm = _mm(s.font_size) or 1.27 + out.append( + f' (text "{_esc(s.text)}" ' + f'(at {_mm(s.x)} {_mm(s.y)} {ang10}) ' + f'(effects (font (size {size_mm} {size_mm}))))') + + # One sub-symbol per unit, matching KiCad's "NAME__ + + +
eda-agent bridge (full-API connection)
+
starting...
+ + + + diff --git a/extensions/easyeda/main.js b/extensions/easyeda/main.js new file mode 100644 index 0000000..c433be9 --- /dev/null +++ b/extensions/easyeda/main.js @@ -0,0 +1,4033 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 George Saliba +// +// The EasyEDA Pro half of the eda-agent bridge. +// +// EasyEDA cannot be driven from outside: the extension API runs inside +// the editor and reaches out. So this extension dials the eda-agent +// server and serves commands, which is the mirror image of the Altium +// bridge where Altium polls a request directory. +// +// Every API name below is taken from EasyEDA's published reference, not +// from recollection. The instance naming is theirs too: a class such as +// PCB_Drc is reached as eda.pcb_Drc, first three letters lowercased. +// That convention is easy to get wrong and silently yields undefined. +// +// WHAT IS NOT ESTABLISHED: none of this has run inside EasyEDA Pro. The +// Python side reports verified_live false for the same reason. Treat a +// clean load as the first test, not as confirmation. + +// Every property EasyEDA assigns on its `eda` object, extracted from +// the constructor in the installed pro-api api.js rather than from a +// convention. Used by the capability probe to ask for each name by +// hand, because a key listing cannot tell a missing class from one +// that is simply not materialised yet. +const KNOWN_EDA_INSTANCES = [ + 'dmt_Board', 'dmt_EditorControl', 'dmt_Folder', 'dmt_Panel', + 'dmt_Pcb', 'dmt_Project', 'dmt_Schematic', 'dmt_SelectControl', + 'dmt_Team', 'dmt_Workspace', 'lib_3DModel', 'lib_Cbb', + 'lib_Classification', 'lib_Device', 'lib_Footprint', + 'lib_LibrariesList', 'lib_PanelLibrary', 'lib_SelectControl', + 'lib_Symbol', 'pcb_Document', 'pcb_Drc', 'pcb_Event', 'pcb_Layer', + 'pcb_ManufactureData', 'pcb_MathPolygon', 'pcb_Net', 'pcb_Primitive', + 'pcb_PrimitiveArc', 'pcb_PrimitiveAttribute', + 'pcb_PrimitiveComponent', 'pcb_PrimitiveDimension', + 'pcb_PrimitiveFill', 'pcb_PrimitiveImage', 'pcb_PrimitiveLine', + 'pcb_PrimitiveObject', 'pcb_PrimitivePad', 'pcb_PrimitivePolyline', + 'pcb_PrimitivePour', 'pcb_PrimitivePoured', 'pcb_PrimitiveRegion', + 'pcb_PrimitiveString', 'pcb_PrimitiveVia', 'pcb_RayTracerEngine', + 'pcb_SelectControl', 'pnl_Document', 'sch_Document', 'sch_Drc', + 'sch_Event', 'sch_ManufactureData', 'sch_Net', 'sch_Netlist', + 'sch_Primitive', 'sch_PrimitiveArc', 'sch_PrimitiveAttribute', + 'sch_PrimitiveBus', 'sch_PrimitiveCircle', 'sch_PrimitiveComponent', + 'sch_PrimitiveObject', 'sch_PrimitivePin', 'sch_PrimitivePolygon', + 'sch_PrimitiveRectangle', 'sch_PrimitiveText', 'sch_PrimitiveWire', + 'sch_SelectControl', 'sch_SimulationEngine', 'sch_Utils', + 'sys_ClientUrl', 'sys_Dialog', 'sys_Environment', 'sys_FileManager', + 'sys_FileSystem', 'sys_FontManager', 'sys_FormatConversion', + 'sys_HeaderMenu', 'sys_I18n', 'sys_IFrame', + 'sys_LoadingAndProgressBar', 'sys_Log', 'sys_Message', + 'sys_MessageBox', 'sys_MessageBus', 'sys_PanelControl', + 'sys_RightClickMenu', 'sys_Setting', 'sys_ShortcutKey', 'sys_Storage', + 'sys_Timer', 'sys_ToastMessage', 'sys_Tool', 'sys_Unit', + 'sys_WebSocket', 'sys_Window', +]; + +//: The socket identity, renewed on every attach. +//: +//: sys_WebSocket.register takes an id, and reusing an id that has +//: already been registered does not reliably establish a new socket. +//: Reconnection after the server restarts therefore fails silently: +//: the close succeeds, register returns, and no connected callback +//: ever arrives. +//: +//: Each attach takes a fresh id and the previous one is closed +//: explicitly, so a reconnect is always a registration the runtime has +//: not seen before. +const WS_ID_BASE = 'eda-agent'; +let wsSerial = 0; +let WS_ID = WS_ID_BASE; + +//: How many times attach() has been entered since load. +//: +//: Counted separately from wsSerial because they fail apart: attach can +//: return before opening anything, so a rising attach count with a flat +//: socket serial says the retry loop is running and giving up before it +//: reaches the socket. +let attachAttempts = 0; + +//: Whether an attach is already in progress. See attach() for why two +//: concurrent scans are fatal rather than merely wasteful. +let attaching = false; +//: When the in-flight attach began, so a stalled one can expire. +let attachingSince = 0; +//: How long an attach may hold the in-flight flag before another +//: is allowed to take over. Longer than a full port walk, short +//: enough that a wedged attach costs one retry window rather than +//: the rest of the session. +const ATTACH_STALL_MS = 30000; + +//: When the retry tick last ran, so two armed timers cannot advance the +//: idle counter twice per interval. +let lastTickAt = 0; + +function renewSocketId() { + const previous = WS_ID; + wsSerial += 1; + WS_ID = `${WS_ID_BASE}-${wsSerial}`; + return previous; +} + +// Replaced by build.py with a hash of this file. An extension that is +// installed, enabled and MONTHS OLD looks exactly like a current one in +// the Extensions Manager: same name, same uuid, and a size nobody +// checks. That ambiguity cost a long time once. Reporting the build +// over the wire makes "is the editor running this code?" a question +// with an answer. +// +// Left as 'dev' in the repo so main.js stays loadable on its own, which +// is how the harnesses import it. +const BUILD_ID = 'dev'; + +// One handler per command. The Python side sends {id, command, params} +// and expects {id, result} or {id, error}. Keeping the envelope in one +// place means a new command cannot invent its own reply shape. +//: How long any ONE handler may take before the caller is told it did +//: not answer. +//: +//: Measured over 91 timed calls on a live 111-component board: the +//: slowest individual handler that succeeded took 0.35s, so 15s is a +//: factor of forty clear of anything observed to work. Every hang was +//: unbounded rather than merely slow, which is what makes a ceiling +//: safe here: there is no middle ground of commands that finish in +//: twenty seconds. +//: +//: The two calls in that sample that took a full minute were +//: easyeda_review_snapshot and easyeda_review_board, and neither is a +//: handler. They are Python-side aggregators that issue dozens of +//: editor commands, so their minute is a sum of sub-calls each of +//: which is separately subject to this ceiling. Reading their total as +//: a handler duration would argue for a timeout four times longer than +//: anything needs. +//: How long an export may take. Rendering a board is not a read and +//: cannot be held to a read's budget. +const EXPORT_TIMEOUT_MS = Number( + (typeof process !== 'undefined' && process.env + && process.env.EDA_EXPORT_TIMEOUT_MS) || 120000); + +const HANDLER_TIMEOUT_MS = Number( + (typeof process !== 'undefined' && process.env + && process.env.EDA_HANDLER_TIMEOUT_MS) || 15000); + +const handlers = {}; + +handlers['system.ping'] = async () => ({ + pong: true, + api: 'easyeda-pro', + // Which build of this extension is actually loaded. The smoke script + // recomputes the same id from main.js and reports loudly when they + // differ, which is the only cheap way to tell a months-old install + // from a current one: EasyEDA's Extensions Manager shows the same + // name, uuid and size either way. + build: BUILD_ID, + // Reported rather than assumed, so the Python side can tell which + // document the answers refer to. + document: await currentDocumentKind(), + // Why the extension did or did not reconnect on its own. + // + // Auto-reconnect has now been wrong twice, both times because it was + // built on an assumption about an API with no way to observe it: a + // heartbeat that never detected a dead socket, then an idle reattach + // that did not fire when it should have. Neither could be diagnosed + // from outside, because the only symptom is a connection that is + // not there. + // + // These fields cost nothing and turn the next connection into the + // measurement. timer_kind says whether a retry loop is armed at all + // and which timer API it got: null means startInterval found + // neither, which would explain silence completely. + retry: { + timer_kind: retryTimer ? retryTimer.kind : null, + idle_ticks: idleTicks, + idle_limit: IDLE_REATTACH_TICKS, + retry_ms: RETRY_MS, + believes_connected: connected, + // How many times a socket has been opened since the extension + // loaded, and the id currently registered. + // + // idle_ticks cannot answer the question that matters, because the + // receive callback zeroes it before this handler runs, so a ping + // always reads zero however long the link sat idle. This counter + // is not touched by receiving, so it survives to be read. + // + // It separates the two failures that look identical from outside. + // After the server has been away and come back: a serial that has + // CLIMBED means the retry loop ran and every connection attempt + // failed, while a serial that has NOT MOVED means the loop never + // fired at all. The fixes point in opposite directions. + socket_serial: wsSerial, + socket_id: WS_ID, + attach_attempts: attachAttempts, + }, +}); + +// A generic call into the editor API, so new capability stops costing +// an extension re-import. +// +// THE PROBLEM THIS SOLVES. Every capability used to live in this file, +// so each new command meant new extension code, and EasyEDA installs +// BY VERSION: importing at a version already installed is a silent +// no-op. Adding one read therefore cost a version bump, a rebuild, a +// manual import and a reconnect, and getting any step wrong left the +// editor running old code while everything looked fine. +// +// With this, the Python side composes commands out of one primitive +// and this file stops changing. The existing named handlers stay +// exactly as they are: they are proven, several do real work beyond a +// single call, and replacing them wholesale would trade a friction +// problem for a correctness one. +// +// DESTRUCTIVE METHODS STILL NEED CONFIRM. Without this the shim would +// be a hole straight through every guard in the file: proj.delete_pcb +// asks for confirmation, and eda.dmt_Pcb.deletePcb through a generic +// invoke would not. The check is on the METHOD NAME because that is +// what a caller reaches for, and it is deliberately broad. +const DESTRUCTIVE_METHOD = + /^(delete|remove|clear|destroy|reset|overwrite)/i; + +// Methods that replace existing content wholesale without a name that +// says so. Listed one by one rather than by widening the prefixes +// above, and that restraint is the point: `set` and `import` cover +// dozens of harmless calls, and a guard that fires on setVisible +// teaches a caller to pass confirm=true reflexively, which is worse +// than having no guard at all. +// +// Found by enumerating all 675 methods the runtime exposes across its +// 92 classes and reading the ones whose names imply replacement. The +// prefix list alone missed every entry here. +// +// setNetlist replaces the whole connectivity +// importAutoRoute* replaces all routing +const DESTRUCTIVE_EXACT = [ + 'setNetlist', + 'importAutoRouteSesFile', + 'importAutoRouteJsonFile', +]; + +function looksDestructive(method) { + const name = String(method || ''); + return DESTRUCTIVE_METHOD.test(name) + || DESTRUCTIVE_EXACT.indexOf(name) !== -1; +} + +function resolveApi(className, method) { + if (typeof className !== 'string' || !className) { + throw new Error('class_name is required'); + } + if (typeof method !== 'string' || !method) { + throw new Error('method is required'); + } + const api = eda[className]; + if (!api) { + throw new Error( + `${className} is not present in this runtime. EasyEDA injects a ` + + 'different API surface per document type; call ' + + 'system.capabilities to see what is here.'); + } + const fn = api[method]; + if (typeof fn !== 'function') { + throw new Error(`${className}.${method} is not a function`); + } + return { api: api, fn: fn }; +} + +async function invokeOne(spec) { + const className = spec.class_name; + const method = spec.method; + if (looksDestructive(method) && spec.confirm !== true) { + throw new Error( + `${className}.${method} looks destructive. Pass confirm=true if ` + + 'that is intended.'); + } + const resolved = resolveApi(className, method); + const args = Array.isArray(spec.args) ? spec.args : []; + const value = await resolved.fn.apply(resolved.api, args); + // The value is returned as-is, including null and false. Those are + // how this API declines, and six handlers once reported work they + // had not done by treating a falsey answer as success. + return { class_name: className, method: method, value: value }; +} + +// The fields are read out here rather than passing params straight +// through. A handler that forwards the whole object hides which +// parameters it actually uses, from a reader and from the contract +// guard that checks the two sides agree. +handlers['system.invoke'] = async (params) => invokeOne({ + class_name: params.class_name, + method: params.method, + args: params.args, + confirm: params.confirm, +}); + +handlers['system.batch'] = async (params) => { + const calls = Array.isArray(params.calls) ? params.calls : []; + if (!calls.length) throw new Error('calls must not be empty'); + // Each result is reported individually, in order. One failure in the + // middle must not lose the answers either side of it: a partial + // result that says which part failed is usable, and an exception is + // not. + // EACH CALL GETS ITS OWN CLOCK. + // + // Catching a throw per call is not enough: one + // call that HANGS takes the whole batch past the dispatcher's + // ceiling, and every result either side of it is lost. A batch of + // three probes returned nothing at all because one of them was a + // read already known to stall. + // + // That is the exact failure the per-call reporting exists to + // prevent, so the budget is per call: a staller is recorded as + // failed and the rest still run. + const PER_CALL_MS = Math.max( + 1000, Math.floor(HANDLER_TIMEOUT_MS / Math.max(1, calls.length))); + const out = []; + for (let i = 0; i < calls.length; i += 1) { + const spec = calls[i] || {}; + let timer = null; + try { + out.push(await Promise.race([ + invokeOne(spec), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error( + `did not answer within ${PER_CALL_MS}ms; the call was ` + + 'accepted and never returned')), PER_CALL_MS); + }), + ]).finally(() => { if (timer !== null) clearTimeout(timer); })); + } catch (e) { + out.push({ + class_name: spec.class_name, + method: spec.method, + failed: String((e && e.message) || e), + }); + } + } + return { results: out, count: out.length, + failed: out.filter((r) => r.failed !== undefined).length }; +}; + +handlers['system.capabilities'] = async () => { + // What the editor ACTUALLY injected, in this context, right now. + // + // EasyEDA loads its API per document type: its own pro-api manifest + // declares separate services for default, sch, symbol, pcb and panel. + // On the start page only the reduced default surface exists, so every + // pcb_* and sch_* class is undefined and sixty-four read commands + // fail with "Cannot read properties of undefined". Those failures + // read as sixty-four bugs in the caller. They are not. + // + // One call answers what sixty-four probes only hint at: which classes + // exist here, and what each one can do. + // Enumerating Object.keys(eda) is NOT enough on its own. The first + // live run reported exactly the six classes this file had already + // touched, which is what a lazily-materialised object looks like as + // well as what a restricted one looks like. Those need different + // fixes, so the probe asks for each known name by hand too and + // reports which way each one answers. + const known = KNOWN_EDA_INSTANCES; + const enumerated = Object.keys(eda); + const probed = {}; + for (const name of known) { + let present = false; + try { + present = eda[name] !== undefined && eda[name] !== null; + } catch (e) { present = false; } + probed[name] = present; + } + + const classes = {}; + for (const name of Array.from(new Set( + [...enumerated, ...known.filter((n) => probed[n])])).sort()) { + const instance = eda[name]; + if (!instance || typeof instance !== 'object') continue; + const methods = new Set(); + for (const key of Object.getOwnPropertyNames(instance)) { + if (typeof instance[key] === 'function') methods.add(key); + } + // Instance methods usually live on the prototype, not the object. + const proto = Object.getPrototypeOf(instance); + if (proto && proto !== Object.prototype) { + for (const key of Object.getOwnPropertyNames(proto)) { + if (key === 'constructor') continue; + try { + if (typeof instance[key] === 'function') methods.add(key); + } catch (e) { /* a getter that throws is not a method */ } + } + } + classes[name] = Array.from(methods).sort(); + } + return { + document: await currentDocumentKind(), + class_count: Object.keys(classes).length, + classes, + // The two lists differ when `eda` materialises a property only once + // it is asked for. A name that is absent from `enumerated` and true + // in `probed` was there all along and simply did not show up in a + // key listing. + enumerated: enumerated.slice().sort(), + probed_present: known.filter((n) => probed[n]), + probed_absent: known.filter((n) => !probed[n]), + // EasyEDA's own in-app code reaches the full API through + // `window._EXTAPI_ROOT_` (pro-ui does exactly `this.eda = + // window._EXTAPI_ROOT_`). If the object an extension is handed is a + // reduced one, that root may still hold the rest. + // + // Reported, not used. This says whether the root is reachable and + // what it carries; moving call sites onto it should follow that + // answer rather than an assumption. + extapi_root: (() => { + const out = { reachable: false, where: null, count: 0, sample: [] }; + const candidates = [ + ['globalThis', typeof globalThis !== 'undefined' ? globalThis : null], + ['window', typeof window !== 'undefined' ? window : null], + ['window.top', + (typeof window !== 'undefined' && window.top) ? window.top : null], + ]; + for (const [where, scope] of candidates) { + if (!scope) continue; + let root = null; + try { root = scope._EXTAPI_ROOT_; } catch (e) { root = null; } + if (!root || typeof root !== 'object') continue; + const keys = Object.keys(root); + const present = known.filter((n) => { + try { return root[n] !== undefined && root[n] !== null; } + catch (e) { return false; } + }); + out.reachable = true; + out.where = where; + out.count = present.length; + out.sample = present.slice(0, 12); + out.enumerated_count = keys.length; + break; + } + return out; + })(), + }; +}; + +async function currentDocumentKind() { + // A command aimed at the PCB is meaningless on a schematic tab, and + // finding that out from a confusing error is worse than being told. + try { + const pcb = await eda.dmt_Pcb.getCurrentPcbInfo(); + if (pcb) return 'pcb'; + } catch (e) { /* not a PCB tab */ } + try { + const sch = await eda.dmt_Schematic.getCurrentSchematicInfo(); + if (sch) return 'schematic'; + } catch (e) { /* not a schematic tab */ } + return 'unknown'; +} + +handlers['design.snapshot'] = async () => { + const components = (await eda.pcb_PrimitiveComponent.getAll()) || []; + const parts = []; + const pins = []; + const unreadable = []; + + for (const component of components) { + const designator = + component.designator || component.name || component.primitiveId; + // A component has no `footprintName` and no `value`. `footprint` + // and `component` are objects of the form + // {libraryUuid, uuid, name}, so reading them as strings yields an + // empty value for every part and leaves a footprint review with + // nothing to examine and no way to say so. + const footprintName = + (component.footprint && component.footprint.name) || + component.footprintName || ''; + const deviceName = + (component.component && component.component.name) || ''; + // The per-part parameters live in otherProperty. Its INNER shape is + // not yet measured, so this reads the conventional keys and falls + // back to the device name rather than inventing a structure. + const props = component.otherProperty; + const value = + (props && typeof props === 'object' && + (props.Value || props.value || props.Comment)) || + component.value || deviceName || ''; + parts.push({ + designator: designator, + footprint: footprintName, + device: deviceName, + value: value, + layer: component.layer, + x: component.x, + y: component.y, + rotation: component.rotation, + // Whether the part belongs on the BOM. Measured on a live board + // as a boolean. Passed through UNTOUCHED rather than defaulted, + // because a reader has to be able to tell "ticked off the BOM" + // from "this component did not say", and the two call for + // opposite handling: the first excludes a part from a purchase + // order, the second must never be allowed to. + addIntoBom: component.addIntoBom, + }); + + // Pins come per component; a flat pad list would lose which part + // each pad belongs to, and the snapshot is built from that pairing. + let pads = []; + try { + pads = + (await eda.pcb_PrimitiveComponent.getAllPinsByPrimitiveId( + component.primitiveId, + )) || []; + } catch (e) { + // A read failure is NOT a component without pins. + // + // Swallowing this put the part in the snapshot with no pads at + // all, and the review engine reads that as a design fact: either + // an unconnected part, or nothing to check because there is + // nothing there. The snapshot is what every EDA-agnostic check is + // built on, so a silent [] here becomes a silent wrong answer + // several layers away from the cause. + pads = []; + unreadable.push({ designator: designator, error: String(e) }); + } + for (const pad of pads) { + pins.push({ + designator: designator, + // padNumber FIRST because it is the measured name: every one of + // 354 pads on a live board reported `padNumber`, and neither + // `number` nor `pinNumber` appeared on any of them. With those + // two alone every pin in the snapshot carried an empty number, + // so nothing built on it could say WHICH pin a net reached. + // The other two stay as fallbacks: the per-component pin call + // is a different accessor from the flat pad list, and its shape + // is not separately measured yet. + pin: pad.padNumber || pad.number || pad.pinNumber || '', + net: pad.net || '', + }); + } + } + + const unconnected = pins.filter((p) => !p.net).length; + + const out = { + board_name: await boardName(), + parts: parts, + pins: pins, + unconnected_pads: unconnected, + stats: { footprints: parts.length, pads: pins.length }, + }; + if (unreadable.length) { + out.components_without_readable_pins = unreadable; + out.pins_incomplete = true; + out.warning = + `${unreadable.length} component(s) would not report their pins, so ` + + 'this snapshot understates the connectivity. They appear here ' + + 'with no pads, which is NOT the same as having none.'; + } + return out; +}; + +async function boardName() { + try { + const info = await eda.dmt_Pcb.getCurrentPcbInfo(); + return (info && (info.name || info.title)) || ''; + } catch (e) { + return ''; + } +} + +handlers['design.run_drc'] = async () => { + // The editor's own checker. This project does not reimplement an EDA + // tool's rules: a second opinion that disagrees is worse than none. + const report = await eda.pcb_Drc.check(); + // NOTHING and NO VIOLATIONS are different answers. + // + // normaliseViolations turns a null report into an empty list, so a + // checker that did not run reported violation_count: 0 - a clean + // board, on the last check before somebody orders one. The shape + // handling below was written to avoid exactly that and only covered + // the case where the report EXISTS in an unexpected shape. + if (report === null || report === undefined) { + return { + ran: false, + failed: 'the DRC checker returned nothing, so this is NOT a clean ' + + 'board: the check did not run. Open a PCB document and retry.', + }; + } + const drcProblem = reportProblem(report, 'DRC'); + if (drcProblem) return { ran: false, failed: drcProblem }; + const violations = normaliseViolations(report); + return { ran: true, violation_count: violations.length, + violations: violations }; +}; + +handlers['design.run_erc'] = async () => { + const report = await eda.sch_Drc.check(); + if (report === null || report === undefined) { + return { + ran: false, + failed: 'the ERC checker returned nothing, so this is NOT a clean ' + + 'schematic: the check did not run. Open a schematic and retry.', + }; + } + const ercProblem = reportProblem(report, 'ERC'); + if (ercProblem) return { ran: false, failed: ercProblem }; + const violations = normaliseViolations(report); + return { ran: true, violation_count: violations.length, + violations: violations }; +}; + +// Whether a checker's answer is a REPORT at all, or something this +// cannot enumerate violations from. +// +// Returns null when it is a usable report, or an explanation when it is +// not. Measured: eda.sch_Drc.check() answers with the BOOLEAN false on +// a live schematic. That is not null or undefined, so it sailed past +// the guard above, and normaliseViolations turned it into an empty +// list. The reply was "ran: true, violation_count: 0" - a confident +// clean bill of health from a check that produced no report. +// +// A boolean is a STATUS, not a list of violations. Even `true` cannot +// be read as "clean": nothing in it enumerates what was checked, and a +// review that treats it as zero violations is asserting something it +// was never told. +function reportProblem(report, what) { + if (typeof report === 'boolean') { + return ( + `the ${what} checker answered with the boolean ${report} rather than ` + + `a report, so no violation list exists. This is NOT a clean result: ` + + `nothing was enumerated. Run the check from the editor's own user ` + + `interface to see its findings.` + ); + } + if (Array.isArray(report)) return null; + if (report && typeof report === 'object') { + if (report.violations || report.items || report.result) return null; + return ( + `the ${what} checker returned an object with none of the fields a ` + + `violation list has been seen under (violations, items, result); ` + + `its keys are [${Object.keys(report).join(', ')}]. Reporting zero ` + + `violations from that would be a guess.` + ); + } + return ( + `the ${what} checker answered with ${typeof report}, which carries no ` + + `violation list. This is NOT a clean result.` + ); +} + +function normaliseViolations(report) { + // Shapes differ between the PCB and schematic checkers, and both may + // wrap the list. Reading several shapes beats assuming one and + // silently reporting zero violations on a board that has them. + // + // Only ever called once reportProblem has confirmed the answer is a + // report, so the [] fallback here can no longer stand in for one. + const list = + (Array.isArray(report) && report) || + (report && (report.violations || report.items || report.result)) || + []; + return (Array.isArray(list) ? list : []).map((v) => ({ + description: v.message || v.description || v.rule || String(v), + net: v.net || '', + designator: v.designator || '', + layer: v.layer || '', + })); +} + +handlers['pcb.net_classes'] = async () => ({ + net_classes: (await eda.pcb_Drc.getAllNetClasses()) || [], +}); + +handlers['pcb.differential_pairs'] = async () => ({ + differential_pairs: (await eda.pcb_Drc.getAllDifferentialPairs()) || [], +}); + +handlers['sch.netlist'] = async () => ({ + netlist: (await eda.sch_Netlist.getNetlist()) || null, +}); + +handlers['pcb.components'] = async () => ({ + components: (await eda.pcb_PrimitiveComponent.getAll()) || [], +}); + +handlers['pcb.nets'] = async () => ({ + nets: (await eda.pcb_Net.getAllNets()) || [], +}); + +handlers['pcb.net_length'] = async (params) => { + const net = params.net; + if (!net) throw new Error('net is required'); + // Named rather than positional so a caller cannot silently pass the + // wrong argument, which on a length query returns a plausible number. + return { net: net, length: await eda.pcb_Net.getNetLength(net) }; +}; + +handlers['pcb.highlight_net'] = async (params) => { + const net = params.net; + if (!net) throw new Error('net is required'); + await eda.pcb_Net.highlightNet(net); + return { net: net, highlighted: true }; +}; + +// Routed length for every net, in ONE round trip. +// +// The per-net call already exists, and asking it net by net is a +// request each: a board with two hundred nets would cost two hundred +// round trips over a socket, which is the difference between a check +// somebody runs and one they do not. The loop belongs on this side. +handlers['pcb.net_lengths'] = async () => { + const nets = (await eda.pcb_Net.getAllNets()) || []; + const lengths = []; + for (const net of nets) { + // Measured: getAllNets returns objects shaped + // {color, length, net}. The NAME field is `net`, and the first + // version of this handler read `.name`, skipped every entry, and + // reported a clean empty that read as "no nets". + const name = typeof net === 'string' ? net : (net && (net.net || net.name)); + if (!name) continue; + let length = (net && typeof net.length === 'number') ? net.length : null; + if (length === null) { + try { + length = await eda.pcb_Net.getNetLength(name); + } catch (e) { + // A net the editor cannot measure is reported as unmeasured + // rather than as zero, which would read as unrouted. + length = null; + } + } + lengths.push({ net: name, length }); + } + return { lengths, count: lengths.length }; +}; + +handlers['pcb.layers'] = async () => ({ + layers: (await eda.pcb_Layer.getAllLayers()) || [], +}); + +handlers['pcb.list_boards'] = async () => ({ + boards: (await eda.dmt_Pcb.getAllPcbsInfo()) || [], +}); + +handlers['sch.components'] = async () => ({ + components: (await eda.sch_PrimitiveComponent.getAll()) || [], +}); + +// Exports return file content from the editor rather than writing to +// disk here: the extension runs in a sandbox and has no path the server +// can agree on. The server writes what comes back. +async function packedFile(value) { + // EasyEDA's manufacture exports return FILE DATA, a Blob: their own + // docs save the result with sys_FileSystem.saveFile(). This bridge + // sends JSON, and JSON.stringify(blob) is {}, so every export used + // to arrive as an empty object and read as a failed export. Measured + // measured: gerber, bom, netlist and the rest all arrive empty. + // + // So the blob becomes base64 here, in chunks: String.fromCharCode + // over a whole multi-megabyte buffer blows the argument limit. + if (value === null || value === undefined) return null; + if (typeof value === 'string') { + return { kind: 'text', size: value.length, text: value }; + } + if (typeof value.arrayBuffer === 'function') { + const buffer = new Uint8Array(await value.arrayBuffer()); + let binary = ''; + const CHUNK = 0x8000; + for (let i = 0; i < buffer.length; i += CHUNK) { + binary += String.fromCharCode.apply( + null, buffer.subarray(i, i + CHUNK)); + } + return { + kind: 'base64', + size: buffer.length, + name: typeof value.name === 'string' ? value.name : undefined, + mime: typeof value.type === 'string' ? value.type : undefined, + base64: btoa(binary), + }; + } + // Something else entirely; hand it over as-is so the caller can see + // what it was rather than a silent null. + return { kind: 'raw', value: value }; +} + +handlers['export.bom'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getBomFile()), +}); + +handlers['export.dxf'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getDxfFile()), +}); + +handlers['export.model_3d'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.get3DFile()), +}); + +handlers['pcb.vias'] = async () => ({ + vias: (await eda.pcb_PrimitiveVia.getAll()) || [], +}); + +handlers['pcb.lines'] = async () => ({ + lines: (await eda.pcb_PrimitiveLine.getAll()) || [], +}); + +handlers['pcb.pads'] = async () => ({ + pads: (await eda.pcb_PrimitivePad.getAll()) || [], +}); + +handlers['export.gerber'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getGerberFile()), +}); + +handlers['export.ipc2581'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getIpc2581CFile()), +}); + +handlers['export.ipcd356'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getIpcD356AFile()), +}); + +handlers['export.netlist'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getNetlistFile()), +}); + +handlers['export.altium'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getAltiumDesignerFile()), +}); + +handlers['export.pdf'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getPdfFile()), +}); + +handlers['export.pick_and_place'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getPickAndPlaceFile()), +}); + +handlers['export.test_points'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getTestPointFile()), +}); + +handlers['export.flying_probe'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getFlyingProbeTestFile()), +}); + +handlers['export.dsn'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getDsnFile()), +}); + +handlers['export.pads'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getPadsFile()), +}); + +handlers['export.pcb_info'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getPcbInfoFile()), +}); + +handlers['export.schematic_document'] = async () => ({ + file: await packedFile(await eda.sch_ManufactureData.getExportDocumentFile()), +}); + +handlers['export.schematic_netlist'] = async () => ({ + file: await packedFile(await eda.sch_ManufactureData.getNetlistFile()), +}); + +handlers['pcb.save'] = async () => { + // Only an EXPLICIT false is treated as a decline. + // + // Hardcoding {saved: true} meant the caller was told it worked whatever + // the editor answered, which is the same defect found in + // pcb.modify_component: the result was discarded and success + // asserted. Undefined is left alone rather than read as failure, + // because a void API returns undefined and calling that a failure + // would invent a decline that never happened. Whether these methods + // return anything at all is unmeasured. + const answer = await eda.pcb_Document.save(); + if (answer === false) { + return { saved: false, failed: 'the editor declined to save' }; + } + return { saved: true }; +}; + +handlers['pcb.clear_routing'] = async (params) => { + // Destructive and not undoable through this channel, so it refuses + // unless the caller said so explicitly. The same reasoning as the + // Altium side's confirm_delete_all: an agent that can erase every + // track by accident will eventually do it. + if (params.confirm !== true) { + throw new Error( + 'clear_routing removes existing routing and is not undoable from ' + + 'here. Pass confirm=true if that is intended.', + ); + } + await eda.pcb_Document.clearRouting(); + return { cleared: true }; +}; + +handlers['pcb.primitives_in_region'] = async (params) => { + const { x1, y1, x2, y2 } = params; + if ([x1, y1, x2, y2].some((v) => typeof v !== 'number')) { + throw new Error('x1, y1, x2 and y2 are required and must be numbers'); + } + return { + primitives: + (await eda.pcb_Document.getPrimitivesInRegion(x1, y1, x2, y2)) || [], + }; +}; + +// ---- writing to the board ------------------------------------------- +// +// Everything above reads. These create primitives, which is what makes +// this backend able to change a board rather than only describe one. +// +// Layers and alignment cross the wire as names, never numbers. EasyEDA's +// layer ids are a NUMERIC enum, and their own guidance is to use the +// members rather than the values, so the number is looked up from the +// runtime's enum at call time. A hardcoded table here would be a second +// copy of their numbering, silently wrong the day they insert a layer. + +// Read an enum EasyEDA injects as a bare global, without assuming it is +// there. Passing the identifier directly to a function would throw +// ReferenceError at the call site before any check inside could run, and +// that error names neither the enum nor the fact that it takes out every +// placement command at once. +function injectedEnum(name) { + const found = typeof globalThis !== 'undefined' + ? globalThis[name] : undefined; + if (found === undefined || found === null) { + throw new Error( + `${name} is not available in this EasyEDA runtime, so no name from ` + + 'it can be resolved to a value. Every command that needs one ' + + 'is affected, not this one alone.', + ); + } + return found; +} + +function enumValue(enumObject, name, what) { + if (typeof name !== 'string' || !name) { + throw new Error(`${what} is required, as one of its names`); + } + const key = name.toUpperCase(); + const value = enumObject ? enumObject[key] : undefined; + if (typeof value !== 'number') { + const known = enumObject + ? Object.keys(enumObject).filter((k) => Number.isNaN(Number(k))) + : []; + throw new Error( + `${what} "${name}" is not a known value. Known: ${known.join(', ')}`, + ); + } + return value; +} + +function requireNumbers(params, names) { + for (const name of names) { + if (typeof params[name] !== 'number' || !Number.isFinite(params[name])) { + throw new Error(`${name} is required and must be a number`); + } + } +} + +// The net a primitive belongs to. Silkscreen and outline lines have no +// net, so an empty string is legitimate rather than a missing argument. +function netOf(params) { + return typeof params.net === 'string' ? params.net : ''; +} + +handlers['pcb.add_line'] = async (params) => { + requireNumbers(params, ['start_x', 'start_y', 'end_x', 'end_y']); + const layer = enumValue(injectedEnum('EPCB_LayerId'), params.layer, 'layer'); + const line = await eda.pcb_PrimitiveLine.create( + netOf(params), + layer, + params.start_x, + params.start_y, + params.end_x, + params.end_y, + typeof params.width === 'number' ? params.width : undefined, + params.locked === true, + ); + return { created: line || null }; +}; + +handlers['pcb.add_arc'] = async (params) => { + requireNumbers(params, ['start_x', 'start_y', 'end_x', 'end_y', 'angle']); + const layer = enumValue(injectedEnum('EPCB_LayerId'), params.layer, 'layer'); + const arc = await eda.pcb_PrimitiveArc.create( + netOf(params), + layer, + params.start_x, + params.start_y, + params.end_x, + params.end_y, + params.angle, + typeof params.width === 'number' ? params.width : undefined, + ); + return { created: arc || null }; +}; + +handlers['pcb.add_via'] = async (params) => { + requireNumbers(params, ['x', 'y', 'hole_diameter', 'diameter']); + if (params.diameter <= params.hole_diameter) { + // The API would take it and produce a via with no annular ring, + // which is a board that cannot be made rather than an error anyone + // would notice on screen. + throw new Error( + `diameter (${params.diameter}) must exceed hole_diameter ` + + `(${params.hole_diameter}), or the via has no annular ring`, + ); + } + const via = await eda.pcb_PrimitiveVia.create( + netOf(params), + params.x, + params.y, + params.hole_diameter, + params.diameter, + ); + return { created: via || null }; +}; + +handlers['pcb.add_text'] = async (params) => { + requireNumbers(params, ['x', 'y', 'font_size', 'width']); + if (typeof params.text !== 'string' || !params.text) { + throw new Error('text is required'); + } + const layer = enumValue(injectedEnum('EPCB_LayerId'), params.layer, 'layer'); + const align = enumValue( + injectedEnum('EPCB_PrimitiveStringAlignMode'), + params.align || 'LEFT_BOTTOM', + 'align', + ); + const string = await eda.pcb_PrimitiveString.create( + layer, + params.x, + params.y, + params.text, + typeof params.font === 'string' && params.font ? params.font : 'NotoSans', + params.font_size, + params.width, + align, + typeof params.rotation === 'number' ? params.rotation : 0, + params.reverse === true, + typeof params.expansion === 'number' ? params.expansion : 0, + params.mirror === true, + params.locked === true, + ); + return { created: string || null }; +}; + +// Build the IPCB_Polygon that pours and polylines both take. +// +// EasyEDA's polygon source is one FLAT array: a start coordinate, then +// a command letter and its arguments, repeating. 'L' is a line segment. +// Their published example does not repeat the start point at the end, +// so a caller that closed the ring themselves would produce a +// zero-length segment; that trailing duplicate is dropped rather than +// passed on. +// +// Shared rather than written twice: the two callers would otherwise +// each carry their own copy of that closing rule, and the version that +// got it wrong would still draw a shape. +function polygonFrom(params, minimum) { + const points = Array.isArray(params.points) ? params.points : []; + if (points.length < minimum) { + throw new Error(`points must be at least ${minimum} [x, y] pairs`); + } + for (const p of points) { + if (!Array.isArray(p) || p.length !== 2 + || typeof p[0] !== 'number' || typeof p[1] !== 'number') { + throw new Error('each point must be a pair of numbers, [x, y]'); + } + } + + const ring = points.slice(); + const first = ring[0]; + const last = ring[ring.length - 1]; + if (ring.length > minimum && first[0] === last[0] && first[1] === last[1]) { + ring.pop(); + } + + const source = [ring[0][0], ring[0][1]]; + for (const [x, y] of ring.slice(1)) { + source.push('L', x, y); + } + + const polygon = eda.pcb_MathPolygon.createPolygon(source); + if (!polygon) { + throw new Error('the editor rejected the polygon outline'); + } + return polygon; +} + +handlers['pcb.add_polyline'] = async (params) => { + const layer = enumValue(injectedEnum('EPCB_LayerId'), params.layer, 'layer'); + const polyline = await eda.pcb_PrimitivePolyline.create( + netOf(params), + layer, + polygonFrom(params, 2), + typeof params.width === 'number' ? params.width : undefined, + params.locked === true, + ); + return { created: polyline || null }; +}; + +handlers['pcb.select'] = async (params) => { + const ids = Array.isArray(params.primitive_ids) ? params.primitive_ids : []; + if (!ids.length) { + throw new Error('primitive_ids is required and must not be empty'); + } + return { + selected: await eda.pcb_SelectControl.doSelectPrimitives(ids) === true, + count: ids.length, + }; +}; + +// Pad shapes and holes are TUPLES, not objects: [shape, w, h] and +// [holeType, diameter]. The first element is an enum member read from +// the runtime, for the same reason the layer ids are. +const PAD_SHAPES = ['ELLIPSE', 'RECTANGLE', 'OBLONG', 'REGULAR_POLYGON']; + +function padShape(params) { + const name = String(params.shape || 'ELLIPSE').toUpperCase(); + if (!PAD_SHAPES.includes(name)) { + throw new Error(`shape must be one of: ${PAD_SHAPES.join(', ')}`); + } + const shapes = injectedEnum('EPCB_PrimitivePadShapeType'); + const kind = shapes[name]; + if (kind === undefined) { + throw new Error( + `EPCB_PrimitivePadShapeType has no member ${name} in this runtime`); + } + requireNumbers(params, ['width']); + if (name === 'REGULAR_POLYGON') { + // Second number is a SIDE COUNT here, not a height. Passing a + // height would silently make a polygon with that many sides. + const sides = typeof params.sides === 'number' ? params.sides : 0; + if (sides <= 2) { + throw new Error('a regular polygon needs sides greater than 2'); + } + return [kind, params.width, sides]; + } + const height = typeof params.height === 'number' + ? params.height : params.width; + if (name === 'RECTANGLE') { + return [kind, params.width, height, + typeof params.corner_radius === 'number' ? params.corner_radius : 0]; + } + return [kind, params.width, height]; +} + +function padHole(params) { + const diameter = params.hole_diameter; + if (typeof diameter !== 'number' || diameter <= 0) { + return null; // a surface-mount pad: no hole is the normal case + } + const holes = injectedEnum('EPCB_PrimitivePadHoleType'); + const length = params.hole_length; + if (typeof length === 'number' && length > diameter) { + return [holes.SLOT, diameter, length]; + } + return [holes.ROUND, diameter]; +} + +handlers['pcb.add_pads'] = async (params) => { + const pads = Array.isArray(params.pads) ? params.pads : []; + if (!pads.length) { + throw new Error('pads must not be empty'); + } + const results = []; + let placed = 0; + let stopped = false; + for (const pad of pads) { + if (stopped) { + results.push({ pad_number: pad && pad.pad_number, ok: false, + skipped: true, error: 'an earlier pad in this batch failed' }); + continue; + } + if (typeof (pad && pad.pad_number) !== 'string' || !pad.pad_number) { + results.push({ pad_number: null, ok: false, + error: 'pad_number is required' }); + stopped = true; + continue; + } + try { + // Built through the SAME helpers the single-pad handler uses. + // Spelling the create call out again here would be a second copy + // of an eight-argument signature, and the shape and hole + // arguments are themselves argument LISTS whose length varies by + // shape: a rectangle carries a corner radius, a regular polygon + // carries a side count where a height would go. + requireNumbers(pad, ['x', 'y']); + const created = await eda.pcb_PrimitivePad.create( + enumValue(injectedEnum('EPCB_LayerId'), pad.layer || 'TOP', 'layer'), + pad.pad_number, + pad.x, + pad.y, + typeof pad.rotation === 'number' ? pad.rotation : 0, + padShape(pad), + netOf(pad), + padHole(pad), + ); + results.push({ pad_number: pad.pad_number, ok: Boolean(created) }); + if (created) placed += 1; + else stopped = true; + } catch (e) { + results.push({ pad_number: pad && pad.pad_number, ok: false, + error: String(e) }); + stopped = true; + } + } + return { placed, of: pads.length, results, stopped }; +}; + +handlers['pcb.add_pad'] = async (params) => { + requireNumbers(params, ['x', 'y']); + if (typeof params.pad_number !== 'string' || !params.pad_number) { + // Numbered by string, and it is what ties the pad to a symbol pin. + // An unnumbered pad is copper the netlist cannot reach. + throw new Error('pad_number is required'); + } + const layer = enumValue( + injectedEnum('EPCB_LayerId'), params.layer || 'TOP', 'layer'); + const pad = await eda.pcb_PrimitivePad.create( + layer, + params.pad_number, + params.x, + params.y, + typeof params.rotation === 'number' ? params.rotation : 0, + padShape(params), + netOf(params), + padHole(params), + ); + return { created: pad || null }; +}; + +// What a region forbids. A region with no rule is just an outline: it +// draws, it constrains nothing, and the board routes straight through +// the area somebody meant to protect. +const REGION_RULES = ['NO_COMPONENTS', 'NO_WIRES', 'NO_FILLS', 'NO_POURS', + 'NO_INNER_ELECTRICAL_LAYERS', 'FOLLOW_REGION_RULE']; + +// Each dimension type wants a DIFFERENT number of points, and they are +// not interchangeable: a length needs four, a radius and an angle need +// three, and the meaning of each point differs per type. Passing the +// wrong count is the failure worth catching here, because a dimension +// drawn from the wrong points still draws. +const DIMENSION_POINTS = { LENGTH: 4, RADIUS: 3, ANGLE: 3 }; + +handlers['pcb.add_dimension'] = async (params) => { + const typeName = String(params.dimension_type || '').toUpperCase(); + const wanted = DIMENSION_POINTS[typeName]; + if (!wanted) { + throw new Error( + `dimension_type must be one of: ${Object.keys(DIMENSION_POINTS).join(', ')}`, + ); + } + const points = Array.isArray(params.points) ? params.points : []; + if (points.length !== wanted) { + throw new Error( + `a ${typeName} dimension takes exactly ${wanted} [x, y] points, ` + + `and ${points.length} were given`, + ); + } + const flat = []; + for (const p of points) { + if (!Array.isArray(p) || p.length !== 2 + || typeof p[0] !== 'number' || typeof p[1] !== 'number') { + throw new Error('each point must be a pair of numbers, [x, y]'); + } + flat.push(p[0], p[1]); + } + const types = injectedEnum('EPCB_PrimitiveDimensionType'); + const kind = types[typeName]; + if (kind === undefined) { + throw new Error( + `EPCB_PrimitiveDimensionType has no member ${typeName} here`); + } + const layer = enumValue( + injectedEnum('EPCB_LayerId'), params.layer || 'DOCUMENT', 'layer'); + const dimension = await eda.pcb_PrimitiveDimension.create( + kind, flat, layer, undefined, + typeof params.width === 'number' ? params.width : undefined, + typeof params.precision === 'number' ? params.precision : undefined, + ); + return { created: dimension || null }; +}; + +handlers['pcb.add_fill'] = async (params) => { + const layer = enumValue(injectedEnum('EPCB_LayerId'), params.layer, 'layer'); + const fill = await eda.pcb_PrimitiveFill.create( + layer, + polygonFrom(params, 3), + netOf(params), + undefined, + typeof params.width === 'number' ? params.width : undefined, + params.locked === true, + ); + return { created: fill || null }; +}; + +handlers['pcb.add_region'] = async (params) => { + const layer = enumValue(injectedEnum('EPCB_LayerId'), params.layer, 'layer'); + const wanted = Array.isArray(params.rules) ? params.rules : []; + if (!wanted.length) { + throw new Error( + `rules is required, as one or more of: ${REGION_RULES.join(', ')}. ` + + 'A region with no rule constrains nothing.', + ); + } + const kinds = injectedEnum('EPCB_PrimitiveRegionRuleType'); + const ruleTypes = wanted.map((name) => { + const key = String(name).toUpperCase(); + if (!REGION_RULES.includes(key) || kinds[key] === undefined) { + throw new Error(`rules must be from: ${REGION_RULES.join(', ')}`); + } + return kinds[key]; + }); + const region = await eda.pcb_PrimitiveRegion.create( + layer, + polygonFrom(params, 3), + ruleTypes, + typeof params.name === 'string' && params.name ? params.name : undefined, + typeof params.width === 'number' ? params.width : undefined, + params.locked === true, + ); + return { created: region || null }; +}; + +handlers['pcb.add_zone'] = async (params) => { + const layer = enumValue(injectedEnum('EPCB_LayerId'), params.layer, 'layer'); + const pour = await eda.pcb_PrimitivePour.create( + netOf(params), + layer, + polygonFrom(params, 3), + undefined, + params.preserve_islands === true, + typeof params.name === 'string' && params.name ? params.name : undefined, + typeof params.priority === 'number' ? params.priority : undefined, + typeof params.width === 'number' ? params.width : undefined, + ); + return { created: pour || null }; +}; + +handlers['pcb.import_changes'] = async (params) => { + // The schematic-to-board update: EasyEDA's equivalent of an ECO. It + // can remove components the schematic no longer has, and their + // routing with them, so it is not a read. + if (params.confirm !== true) { + throw new Error( + 'import_changes applies the schematic to the board, which can ' + + 'remove components and their routing. Pass confirm=true if ' + + 'that is intended.', + ); + } + const uuid = typeof params.schematic_uuid === 'string' + && params.schematic_uuid ? params.schematic_uuid : undefined; + return { imported: await eda.pcb_Document.importChanges(uuid) === true }; +}; + +handlers['pcb.zoom_to_board'] = async () => ({ + zoomed: await eda.pcb_Document.zoomToBoardOutline() === true, +}); + +const SCH_DELETERS = { + wire: () => eda.sch_PrimitiveWire, + text: () => eda.sch_PrimitiveText, + rectangle: () => eda.sch_PrimitiveRectangle, + component: () => eda.sch_PrimitiveComponent, + attribute: () => eda.sch_PrimitiveAttribute, +}; + +handlers['sch.delete_primitives'] = async (params) => { + if (params.confirm !== true) { + // The Python tool checks this too. Both halves check because this + // channel is reachable by anything speaking the protocol, so it + // cannot assume a caller already asked. + throw new Error( + 'delete_primitives removes objects and is not undoable from here. ' + + 'Pass confirm=true if that is intended.', + ); + } + const kind = String(params.kind || '').toLowerCase(); + if (!Object.prototype.hasOwnProperty.call(SCH_DELETERS, kind)) { + throw new Error( + `kind must be one of: ${Object.keys(SCH_DELETERS).join(', ')}`, + ); + } + const ids = Array.isArray(params.primitive_ids) ? params.primitive_ids : []; + if (!ids.length) { + throw new Error('primitive_ids is required and must not be empty'); + } + const deleted = await SCH_DELETERS[kind]().delete(ids); + return { deleted: deleted === true, count: ids.length, kind }; +}; + +// ---- layers --------------------------------------------------------- + +// Every copper layer count EasyEDA accepts. Stated by their signature as +// a union of literals, so an unlisted number is rejected here with the +// list rather than sent and refused with nothing useful said. +const COPPER_LAYER_COUNTS = [ + 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, +]; + +function layerList(params) { + const names = Array.isArray(params.layers) ? params.layers : []; + if (!names.length) { + throw new Error('layers is required and must not be empty'); + } + const layerEnum = injectedEnum('EPCB_LayerId'); + return names.map((n) => enumValue(layerEnum, n, 'layer')); +} + +handlers['pcb.set_copper_layer_count'] = async (params) => { + const count = params.count; + if (!COPPER_LAYER_COUNTS.includes(count)) { + throw new Error( + `count must be one of: ${COPPER_LAYER_COUNTS.join(', ')}`, + ); + } + // Reducing the count discards what was on the layers that go away. + if (params.confirm !== true) { + throw new Error( + 'changing the copper layer count restructures the stackup and ' + + 'discards anything on a layer that is removed. Pass ' + + 'confirm=true if that is intended.', + ); + } + return { + set: await eda.pcb_Layer.setTheNumberOfCopperLayers(count) === true, + count, + }; +}; + +handlers['pcb.set_layer_visibility'] = async (params) => { + const layers = layerList(params); + const visible = params.visible !== false; + const exclusive = params.exclusive === true; + const ok = visible + ? await eda.pcb_Layer.setLayerVisible(layers, exclusive) + : await eda.pcb_Layer.setLayerInvisible(layers, exclusive); + return { applied: ok === true, visible, exclusive }; +}; + +handlers['pcb.set_layer_lock'] = async (params) => { + const layers = layerList(params); + const locked = params.locked !== false; + const ok = locked + ? await eda.pcb_Layer.lockLayer(layers) + : await eda.pcb_Layer.unlockLayer(layers); + return { applied: ok === true, locked }; +}; + +handlers['pcb.select_layer'] = async (params) => { + const layer = enumValue( + injectedEnum('EPCB_LayerId'), params.layer, 'layer'); + return { selected: await eda.pcb_Layer.selectLayer(layer) === true }; +}; + +handlers['pcb.modify_layer'] = async (params) => { + const layer = enumValue( + injectedEnum('EPCB_LayerId'), params.layer, 'layer'); + const property = {}; + if (typeof params.name === 'string' && params.name) { + property.name = params.name; + } + if (typeof params.color === 'string' && params.color) { + property.color = params.color; + } + if (typeof params.transparency === 'number') { + property.transparency = params.transparency; + } + if (!Object.keys(property).length) { + throw new Error( + 'give at least one of name, color or transparency; an empty ' + + 'change reports success while doing nothing', + ); + } + return { + modified: await eda.pcb_Layer.modifyLayer(layer, property) === true, + }; +}; + +// ---- design rules --------------------------------------------------- + +// The colour a group is drawn in. EasyEDA takes { r, g, b, alpha } or +// null, and null means "you choose". Defaulting to null rather than to +// some colour picked here keeps this from quietly restyling a board. +function groupColour(params) { + const c = params.color; + if (!c || typeof c !== 'object') return null; + const { r, g, b } = c; + if ([r, g, b].some((v) => typeof v !== 'number')) { + throw new Error('color must be {r, g, b} with an optional alpha'); + } + return { r, g, b, alpha: typeof c.alpha === 'number' ? c.alpha : 1 }; +} + +function requireNets(params) { + const nets = Array.isArray(params.nets) ? params.nets : []; + if (!nets.length || nets.some((n) => typeof n !== 'string' || !n)) { + throw new Error('nets is required and must be non-empty net names'); + } + return nets; +} + +function requireName(params) { + if (typeof params.name !== 'string' || !params.name) { + throw new Error('name is required'); + } + return params.name; +} + +handlers['pcb.create_net_class'] = async (params) => ({ + created: await eda.pcb_Drc.createNetClass( + requireName(params), requireNets(params), groupColour(params)) === true, +}); + +handlers['pcb.add_nets_to_net_class'] = async (params) => ({ + added: await eda.pcb_Drc.addNetToNetClass( + requireName(params), requireNets(params)) === true, +}); + +handlers['pcb.create_differential_pair'] = async (params) => { + const positive = params.positive_net; + const negative = params.negative_net; + if (typeof positive !== 'string' || !positive + || typeof negative !== 'string' || !negative) { + throw new Error('positive_net and negative_net are both required'); + } + if (positive === negative) { + // The editor would take it and produce a pair of one net with + // itself, which routes as a pair and is not one. + throw new Error('positive_net and negative_net must differ'); + } + return { + created: await eda.pcb_Drc.createDifferentialPair( + requireName(params), positive, negative) === true, + }; +}; + +handlers['pcb.create_length_match_group'] = async (params) => ({ + created: await eda.pcb_Drc.createEqualLengthNetGroup( + requireName(params), requireNets(params), groupColour(params)) === true, +}); + +handlers['pcb.add_nets_to_length_match_group'] = async (params) => ({ + added: await eda.pcb_Drc.addNetToEqualLengthNetGroup( + requireName(params), requireNets(params)) === true, +}); + +handlers['pcb.net_rules'] = async () => ({ + rules: (await eda.pcb_Drc.getNetRules()) || [], +}); + +handlers['pcb.rule_configurations'] = async () => ({ + configurations: (await eda.pcb_Drc.getAllRuleConfigurations()) || [], + current: await eda.pcb_Drc.getCurrentRuleConfigurationName(), +}); + +handlers['pcb.length_match_groups'] = async () => ({ + groups: (await eda.pcb_Drc.getAllEqualLengthNetGroups()) || [], +}); + +// ---- placing library parts ------------------------------------------ +// +// A part is identified by the pair EasyEDA's own search returns, +// { libraryUuid, uuid }. Nothing here looks a part up by name: two +// libraries can hold the same name, and picking one silently is how a +// board ends up with the wrong footprint on a part that reads correctly +// in the BOM. + +function libraryRef(params) { + const libraryUuid = params.library_uuid; + const uuid = params.uuid; + if (typeof libraryUuid !== 'string' || !libraryUuid + || typeof uuid !== 'string' || !uuid) { + throw new Error( + 'library_uuid and uuid are both required. Both come from a search ' + + 'result; there is no lookup by part name.', + ); + } + return { libraryUuid, uuid }; +} + +// Place many parts in ONE round trip. +// +// Each placement is reported individually. A batch that half-succeeds +// is the case worth designing for: the parts that landed are on the +// sheet, and a caller needs to know which so the retry does not double +// them up. +handlers['sch.place_components'] = async (params) => { + const items = Array.isArray(params.components) ? params.components : []; + if (!items.length) { + throw new Error('components must not be empty'); + } + const results = []; + let placed = 0; + let stopped = false; + for (const item of items) { + if (stopped) { + // STOPS at the first failure, and says the rest were not tried. + // + // This batch replaces a sequence of individual calls, and that + // sequence stopped on failure. Carrying on here would place parts + // around the hole where the failed one belongs and report a count, + // which is the outcome batching was supposed to make no worse. + results.push({ uuid: item && item.uuid, ok: false, + skipped: true, error: 'an earlier placement in this batch failed' }); + continue; + } + try { + const created = await eda.sch_PrimitiveComponent.create( + libraryRef(item), + item.x, + item.y, + undefined, + typeof item.rotation === 'number' ? item.rotation : 0, + item.mirror === true, + item.add_to_bom !== false, + item.add_to_pcb !== false, + ); + results.push({ uuid: item.uuid, ok: Boolean(created), + created: created || null }); + if (created) placed += 1; + else stopped = true; + } catch (e) { + results.push({ uuid: item && item.uuid, ok: false, + error: String(e) }); + stopped = true; + } + } + return { placed, of: items.length, results, stopped }; +}; + +handlers['sch.place_component'] = async (params) => { + requireNumbers(params, ['x', 'y']); + const component = await eda.sch_PrimitiveComponent.create( + libraryRef(params), + params.x, + params.y, + typeof params.sub_part === 'string' && params.sub_part + ? params.sub_part : undefined, + typeof params.rotation === 'number' ? params.rotation : 0, + params.mirror === true, + params.add_to_bom !== false, + params.add_to_pcb !== false, + ); + return { created: component || null }; +}; + +handlers['pcb.place_components'] = async (params) => { + const items = Array.isArray(params.components) ? params.components : []; + if (!items.length) { + throw new Error('components must not be empty'); + } + const layerEnum = injectedEnum('EPCB_LayerId'); + const results = []; + let placed = 0; + let stopped = false; + for (const item of items) { + if (stopped) { + results.push({ uuid: item && item.uuid, ok: false, skipped: true, + error: 'an earlier placement in this batch failed' }); + continue; + } + try { + const created = await eda.pcb_PrimitiveComponent.create( + libraryRef(item), + enumValue(layerEnum, item.layer || 'TOP', 'layer'), + item.x, + item.y, + typeof item.rotation === 'number' ? item.rotation : 0, + item.locked === true, + ); + results.push({ uuid: item.uuid, ok: Boolean(created), + created: created || null }); + if (created) placed += 1; + else stopped = true; + } catch (e) { + results.push({ uuid: item && item.uuid, ok: false, error: String(e) }); + stopped = true; + } + } + return { placed, of: items.length, results, stopped }; +}; + +handlers['pcb.place_component'] = async (params) => { + requireNumbers(params, ['x', 'y']); + const layer = enumValue( + injectedEnum('EPCB_LayerId'), params.layer || 'TOP', 'layer'); + const component = await eda.pcb_PrimitiveComponent.create( + libraryRef(params), + layer, + params.x, + params.y, + typeof params.rotation === 'number' ? params.rotation : 0, + params.locked === true, + ); + return { created: component || null }; +}; + +// Modify many components in ONE round trip. +// +// The per-component call already exists, and looping it from the server +// costs a request each: renumbering forty parts is forty round trips +// over a socket, which is the difference between a batch edit and a +// pause. The loop belongs on this side, the same way the net-length +// sweep does. +// +// Each change is reported individually rather than as one verdict. A +// partial failure is the interesting case: knowing THAT something +// failed is no use without knowing which, since the rest did apply and +// the design is now half-edited. +async function modifyEach(modify, changes) { + const results = []; + let applied = 0; + for (const change of changes) { + const id = change && change.primitive_id; + const properties = change && change.changes; + if (typeof id !== 'string' || !id) { + results.push({ primitive_id: id || null, ok: false, + error: 'primitive_id is required' }); + continue; + } + if (!properties || typeof properties !== 'object' + || Array.isArray(properties) || !Object.keys(properties).length) { + results.push({ primitive_id: id, ok: false, + error: 'changes must name at least one property' }); + continue; + } + try { + const out = await modify(id, properties); + results.push({ primitive_id: id, ok: Boolean(out) }); + if (out) applied += 1; + } catch (e) { + results.push({ primitive_id: id, ok: false, error: String(e) }); + } + } + return { applied, of: changes.length, results }; +} + +handlers['sch.modify_components'] = async (params) => { + const changes = Array.isArray(params.changes) ? params.changes : []; + if (!changes.length) { + throw new Error('changes must not be empty'); + } + return modifyEach( + (id, properties) => eda.sch_PrimitiveComponent.modify(id, properties), + changes); +}; + +handlers['pcb.modify_components'] = async (params) => { + const changes = Array.isArray(params.changes) ? params.changes : []; + if (!changes.length) { + throw new Error('changes must not be empty'); + } + return modifyEach( + (id, properties) => eda.pcb_PrimitiveComponent.modify(id, properties), + changes); +}; + +handlers['sch.set_component_properties'] = async (params) => { + if (typeof params.primitive_id !== 'string' || !params.primitive_id) { + throw new Error('primitive_id is required'); + } + const property = params.changes; + if (!property || typeof property !== 'object' || Array.isArray(property)) { + throw new Error('changes is required and must be an object'); + } + const modified = await eda.sch_PrimitiveComponent.modify( + params.primitive_id, property); + return { modified: modified || null }; +}; + +// ---- writing to the schematic --------------------------------------- +// +// No layer here: a schematic sheet has none, so these take no layer name +// and the enum lookup above does not apply. + +// A WIRE OR BUS IS A LIST OF SEGMENTS, NOT A LIST OF POINTS. +// +// sch_PrimitiveWire.create and sch_PrimitiveBus.create take +// [[x1,y1,x2,y2], ...]: each entry is one whole segment as four flat +// numbers. Three call sites passed [[x,y],[x,y]] instead, which is a +// list of malformed segments, and create returned null every time. So +// neither the single wire, the bulk wires, nor the bus had ever drawn +// anything. +// +// Measured, not reasoned: a wire already on a live sheet reports +// line: [[400,-200,300,-200],[300,-200,200,-200]], and sending that +// same shape produced a real wire whose readback held exactly the +// values sent. +// +// Returns segments, or throws with the reason. Whole segments pass +// through untouched, because that is the form the editor reports and +// geometry read back should be able to go straight in again. +function polylineToSegments(points, what) { + const isSegment = (p) => Array.isArray(p) && p.length === 4 + && p.every((n) => typeof n === 'number'); + const isPoint = (p) => Array.isArray(p) && p.length === 2 + && p.every((n) => typeof n === 'number'); + + if (!Array.isArray(points) || points.length === 0) { + throw new Error(`${what} is required`); + } + if (points.every(isSegment)) { + return points.map((p) => [p[0], p[1], p[2], p[3]]); + } + if (points.length < 2) { + throw new Error( + `${what} needs at least 2 [x, y] points, or whole ` + + '[x1, y1, x2, y2] segments', + ); + } + for (const p of points) { + if (!isPoint(p)) { + throw new Error( + `each entry of ${what} must be an [x, y] pair of numbers, or a ` + + 'whole [x1, y1, x2, y2] segment', + ); + } + } + const segments = []; + for (let i = 0; i + 1 < points.length; i += 1) { + segments.push([points[i][0], points[i][1], + points[i + 1][0], points[i + 1][1]]); + } + return segments; +} + +handlers['sch.add_wires'] = async (params) => { + const wires = Array.isArray(params.wires) ? params.wires : []; + if (!wires.length) { + throw new Error('wires must not be empty'); + } + const results = []; + let drawn = 0; + let stopped = false; + for (const wire of wires) { + if (stopped) { + results.push({ net: wire && wire.net, ok: false, skipped: true, + error: 'an earlier wire in this batch failed' }); + continue; + } + let segments; + try { + segments = polylineToSegments(wire && wire.points, 'points'); + } catch (e) { + results.push({ net: wire && wire.net, ok: false, + error: (e && e.message) || String(e) }); + stopped = true; + continue; + } + try { + const created = await eda.sch_PrimitiveWire.create( + segments, + typeof wire.net === 'string' && wire.net ? wire.net : undefined, + ); + results.push({ net: wire.net, ok: Boolean(created) }); + if (created) drawn += 1; + else stopped = true; + } catch (e) { + results.push({ net: wire && wire.net, ok: false, error: String(e) }); + stopped = true; + } + } + return { drawn, of: wires.length, results, stopped }; +}; + +handlers['sch.add_wire'] = async (params) => { + // See polylineToSegments: a wire is segments, not points, and this + // call site was one of the three that had never drawn anything. + const segments = polylineToSegments(params.points, 'points'); + const wire = await eda.sch_PrimitiveWire.create( + segments, + typeof params.net === 'string' && params.net ? params.net : undefined, + ); + return { created: wire || null, segments: segments.length }; +}; + +handlers['sch.add_text'] = async (params) => { + requireNumbers(params, ['x', 'y']); + if (typeof params.text !== 'string' || !params.text) { + throw new Error('text is required'); + } + const text = await eda.sch_PrimitiveText.create( + params.x, + params.y, + params.text, + typeof params.rotation === 'number' ? params.rotation : 0, + null, + typeof params.font === 'string' && params.font ? params.font : null, + typeof params.font_size === 'number' ? params.font_size : null, + params.bold === true, + params.italic === true, + params.underline === true, + ); + return { created: text || null }; +}; + +// Point pairs to the FLAT [x1, y1, x2, y2, ...] array the polygon call +// takes. The wire call accepts either form, so a helper shared between +// them would work on one and be silently reinterpreted by the other. +function flatPoints(params, minimum) { + const points = Array.isArray(params.points) ? params.points : []; + if (points.length < minimum) { + throw new Error(`points must be at least ${minimum} [x, y] pairs`); + } + const flat = []; + for (const p of points) { + if (!Array.isArray(p) || p.length !== 2 + || typeof p[0] !== 'number' || typeof p[1] !== 'number') { + throw new Error('each point must be a pair of numbers, [x, y]'); + } + flat.push(p[0], p[1]); + } + return flat; +} + +// The electrical character of a pin, which is what ERC checks. Getting +// it wrong does not draw differently: two outputs tied together look +// exactly like an output driving an input, and only ERC can tell. +const PIN_TYPES = ['BI', 'GROUND', 'HIZ', 'IN', 'OPEN_COLLECTOR', + 'OPEN_EMITTER', 'OUT', 'PASSIVE', 'POWER', 'TERMINATOR', 'UNDEFINED']; + +handlers['sch.add_bus'] = async (params) => { + // The bus NAME is what carries its members, e.g. D[0..7]. A bus drawn + // without one is a thick line: it looks like a bus, groups nothing, + // and the signals a reader assumes are in it are not. + if (typeof params.name !== 'string' || !params.name) { + throw new Error('name is required, e.g. "D[0..7]"'); + } + const segments = polylineToSegments(params.points, 'points'); + const bus = await eda.sch_PrimitiveBus.create(params.name, segments); + return { created: bus || null, segments: segments.length }; +}; + +handlers['sch.add_pins'] = async (params) => { + const pins = Array.isArray(params.pins) ? params.pins : []; + if (!pins.length) { + throw new Error('pins must not be empty'); + } + const types = injectedEnum('ESCH_PrimitivePinType'); + const results = []; + let placed = 0; + let stopped = false; + for (const pin of pins) { + if (stopped) { + results.push({ pin_number: pin && pin.pin_number, ok: false, + skipped: true, error: 'an earlier pin in this batch failed' }); + continue; + } + const typeName = String((pin && pin.pin_type) || 'UNDEFINED') + .toUpperCase(); + if (!PIN_TYPES.includes(typeName) || types[typeName] === undefined) { + results.push({ pin_number: pin && pin.pin_number, ok: false, + error: `pin_type must be one of: ${PIN_TYPES.join(', ')}` }); + stopped = true; + continue; + } + try { + const created = await eda.sch_PrimitivePin.create( + pin.x, + pin.y, + pin.pin_number, + typeof pin.name === 'string' ? pin.name : undefined, + typeof pin.rotation === 'number' ? pin.rotation : 0, + typeof pin.length === 'number' ? pin.length : undefined, + null, + undefined, + types[typeName], + ); + results.push({ pin_number: pin.pin_number, ok: Boolean(created) }); + if (created) placed += 1; + else stopped = true; + } catch (e) { + results.push({ pin_number: pin && pin.pin_number, ok: false, + error: String(e) }); + stopped = true; + } + } + return { placed, of: pins.length, results, stopped }; +}; + +handlers['sch.add_pin'] = async (params) => { + requireNumbers(params, ['x', 'y']); + if (typeof params.pin_number !== 'string' || !params.pin_number) { + // The pin number is what ties a symbol to its footprint's pads. + // Without it the part draws and cannot be matched to a package. + throw new Error('pin_number is required'); + } + const typeName = String(params.pin_type || 'UNDEFINED').toUpperCase(); + if (!PIN_TYPES.includes(typeName)) { + throw new Error(`pin_type must be one of: ${PIN_TYPES.join(', ')}`); + } + const types = injectedEnum('ESCH_PrimitivePinType'); + const pinType = types[typeName]; + if (pinType === undefined) { + throw new Error( + `ESCH_PrimitivePinType has no member ${typeName} in this runtime`); + } + const pin = await eda.sch_PrimitivePin.create( + params.x, + params.y, + params.pin_number, + typeof params.name === 'string' ? params.name : undefined, + typeof params.rotation === 'number' ? params.rotation : 0, + typeof params.length === 'number' ? params.length : undefined, + null, + undefined, + pinType, + ); + return { created: pin || null }; +}; + +handlers['sch.add_arc'] = async (params) => { + // Three points, not a centre and a sweep: start, a REFERENCE point + // the arc passes through, and end. Feeding a centre as the middle + // pair draws an arc through the centre, which is a plausible-looking + // curve in the wrong place. + requireNumbers(params, [ + 'start_x', 'start_y', 'reference_x', 'reference_y', 'end_x', 'end_y', + ]); + const arc = await eda.sch_PrimitiveArc.create( + params.start_x, params.start_y, + params.reference_x, params.reference_y, + params.end_x, params.end_y); + return { created: arc || null }; +}; + +handlers['sch.add_circle'] = async (params) => { + requireNumbers(params, ['x', 'y', 'radius']); + if (params.radius <= 0) { + throw new Error('radius must be greater than zero'); + } + const circle = await eda.sch_PrimitiveCircle.create( + params.x, params.y, params.radius); + return { created: circle || null }; +}; + +handlers['sch.add_polygon'] = async (params) => { + const polygon = await eda.sch_PrimitivePolygon.create( + flatPoints(params, 3)); + return { created: polygon || null }; +}; + +handlers['sch.selection'] = async () => ({ + primitives: (await eda.sch_SelectControl.getAllSelectedPrimitives()) || [], +}); + +handlers['sch.select'] = async (params) => { + const ids = Array.isArray(params.primitive_ids) ? params.primitive_ids : []; + if (!ids.length) { + throw new Error('primitive_ids is required and must not be empty'); + } + return { + selected: await eda.sch_SelectControl.doSelectPrimitives(ids) === true, + count: ids.length, + }; +}; + +handlers['sch.clear_selection'] = async () => ({ + cleared: await eda.sch_SelectControl.clearSelected() === true, +}); + +handlers['sch.add_rectangle'] = async (params) => { + requireNumbers(params, ['x', 'y', 'width', 'height']); + // x, y is the TOP-LEFT corner, not a centre and not a bottom-left + // one. Getting that wrong puts the rectangle a full height away from + // where it was asked for, which still looks like a plausible drawing. + const rect = await eda.sch_PrimitiveRectangle.create( + params.x, + params.y, + params.width, + params.height, + typeof params.corner_radius === 'number' ? params.corner_radius : 0, + typeof params.rotation === 'number' ? params.rotation : 0, + ); + return { created: rect || null }; +}; + +// Each primitive class deletes only its own kind, so the caller says +// which. Dispatching on a name keeps the id-to-class question with the +// caller, who knows what they created, instead of guessing here from +// the shape of an id. +const DELETERS = { + line: () => eda.pcb_PrimitiveLine, + arc: () => eda.pcb_PrimitiveArc, + via: () => eda.pcb_PrimitiveVia, + text: () => eda.pcb_PrimitiveString, + pad: () => eda.pcb_PrimitivePad, + fill: () => eda.pcb_PrimitiveFill, + region: () => eda.pcb_PrimitiveRegion, + pour: () => eda.pcb_PrimitivePour, + component: () => eda.pcb_PrimitiveComponent, +}; + +handlers['pcb.delete_primitives'] = async (params) => { + if (params.confirm !== true) { + // The Python tool checks this too. Both halves check because this + // channel is reachable by anything speaking the protocol, so it + // cannot assume a caller already asked. + throw new Error( + 'delete_primitives removes objects and is not undoable from here. ' + + 'Pass confirm=true if that is intended.', + ); + } + const kind = String(params.kind || '').toLowerCase(); + if (!Object.prototype.hasOwnProperty.call(DELETERS, kind)) { + throw new Error( + `kind must be one of: ${Object.keys(DELETERS).join(', ')}`, + ); + } + const ids = Array.isArray(params.primitive_ids) ? params.primitive_ids : []; + if (!ids.length) { + throw new Error('primitive_ids is required and must not be empty'); + } + const deleted = await DELETERS[kind]().delete(ids); + return { deleted: deleted === true, count: ids.length, kind }; +}; + +handlers['pcb.navigate'] = async (params) => { + const { x, y } = params; + if (typeof x !== 'number' || typeof y !== 'number') { + throw new Error('x and y are required and must be numbers'); + } + await eda.pcb_Document.navigateToCoordinates(x, y); + return { x: x, y: y }; +}; + +handlers['pcb.auto_route'] = async () => { + // THERE IS NO AUTOROUTE METHOD. This called + // eda.pcb_Document.autoRouting(), which does not exist: the live + // runtime lists nineteen methods on pcb_Document and that is not one + // of them, so every call died on "is not a function" after passing + // the confirm gate. The handler then returned {routed: true}, which + // is what it would have reported had the call succeeded. + // + // What the API does expose is the other half of the round trip: + // importAutoRouteSesFile and importAutoRouteJsonFile take routing + // produced by an external router. So the capability is import, not + // run, and saying so is more useful than a TypeError. + throw new Error( + 'EasyEDA does not expose an autorouter to extensions. pcb_Document ' + + 'has no autoRouting method; what it has is ' + + 'importAutoRouteSesFile and importAutoRouteJsonFile, which load ' + + 'routing produced elsewhere. Route with the editor\'s own ' + + 'autorouter, or route externally and import the result.', + ); +}; + +const PORT_DIRECTIONS = ['IN', 'OUT', 'BI']; + +handlers['sch.create_net_port'] = async (params) => { + const name = params.name; + if (!name) throw new Error('name is required'); + requireNumbers(params, ['x', 'y']); + const direction = String(params.direction || 'BI').toUpperCase(); + if (!PORT_DIRECTIONS.includes(direction)) { + throw new Error( + `direction must be one of: ${PORT_DIRECTIONS.join(', ')}`, + ); + } + const port = await eda.sch_PrimitiveComponent.createNetPort( + direction, name, params.x, params.y, + typeof params.rotation === 'number' ? params.rotation : 0, + params.mirror === true, + ); + return { name, created: port || null }; +}; + +// Power and ground glyphs. EasyEDA calls these net FLAGS, and they are a +// different call from a net port: a port is a sheet-level connector, +// a flag is the rail symbol. Using a port where the convention wants a +// flag draws a schematic that reads wrong to anyone used to the +// convention, and connects correctly, so nothing catches it. +const NET_FLAGS = ['Power', 'Ground', 'AnalogGround', 'ProtectGround']; + +handlers['sch.create_net_flag'] = async (params) => { + const name = params.name; + if (!name) throw new Error('name is required'); + requireNumbers(params, ['x', 'y']); + const kind = String(params.kind || 'Power'); + const match = NET_FLAGS.find( + (f) => f.toLowerCase() === kind.toLowerCase()); + if (!match) { + throw new Error(`kind must be one of: ${NET_FLAGS.join(', ')}`); + } + const flag = await eda.sch_PrimitiveComponent.createNetFlag( + match, name, params.x, params.y, + typeof params.rotation === 'number' ? params.rotation : 0, + params.mirror === true, + ); + return { name, kind: match, created: flag || null }; +}; + +// ---- authoring library items ---------------------------------------- +// +// Three separate objects, and the order matters: a symbol and a +// footprint are drawings, and a DEVICE is what binds them into +// something placeable. Creating the two drawings and stopping leaves a +// library nobody can place from, which looks like progress. + +function libraryUuidOf(params) { + if (typeof params.library_uuid !== 'string' || !params.library_uuid) { + throw new Error( + 'library_uuid is required. It comes from lib.list_libraries; ' + + 'there is no default library to fall back on.', + ); + } + return params.library_uuid; +} + +function itemNameOf(params) { + if (typeof params.name !== 'string' || !params.name) { + throw new Error('name is required'); + } + return params.name; +} + +handlers['lib.create_symbol'] = async (params) => { + const uuid = await eda.lib_Symbol.create( + libraryUuidOf(params), + itemNameOf(params), + undefined, + undefined, + typeof params.description === 'string' ? params.description : undefined, + ); + return { uuid: uuid || null, created: Boolean(uuid) }; +}; + +handlers['lib.create_footprint'] = async (params) => { + const uuid = await eda.lib_Footprint.create( + libraryUuidOf(params), + itemNameOf(params), + undefined, + typeof params.description === 'string' ? params.description : undefined, + ); + return { uuid: uuid || null, created: Boolean(uuid) }; +}; + +handlers['lib.create_device'] = async (params) => { + const association = {}; + if (params.symbol_uuid) { + association.symbol = { + uuid: params.symbol_uuid, + libraryUuid: params.symbol_library_uuid || params.library_uuid, + }; + } + if (params.footprint_uuid) { + association.footprint = { + uuid: params.footprint_uuid, + libraryUuid: params.footprint_library_uuid || params.library_uuid, + }; + } + if (params.model_3d_uuid) { + association.model3D = { + uuid: params.model_3d_uuid, + libraryUuid: params.model_3d_library_uuid || params.library_uuid, + }; + } + // A device with neither a symbol nor a footprint places nothing. The + // API would accept it and report a uuid, so the empty shell would + // read as a created part until somebody tried to use it. + if (!association.symbol && !association.footprint) { + throw new Error( + 'give at least symbol_uuid or footprint_uuid; a device bound to ' + + 'neither cannot be placed and would still report success', + ); + } + const uuid = await eda.lib_Device.create( + libraryUuidOf(params), + itemNameOf(params), + undefined, + association, + typeof params.description === 'string' ? params.description : undefined, + ); + return { uuid: uuid || null, created: Boolean(uuid) }; +}; + +// Opening a library item makes it the ACTIVE document, after which the +// ordinary drawing commands apply to it. That is how a symbol or +// footprint gets its geometry here: there is no separate library +// drawing API, and inventing one would be a second way to draw the same +// shapes. +// +// The uuid pair is (item, library) in that order for these calls, which +// is the reverse of lib.create_* where the library comes first. Getting +// it backwards finds nothing and reports no error. + +function itemAndLibrary(params) { + const uuid = params.uuid; + const libraryUuid = params.library_uuid; + if (typeof uuid !== 'string' || !uuid + || typeof libraryUuid !== 'string' || !libraryUuid) { + throw new Error('uuid and library_uuid are both required'); + } + return [uuid, libraryUuid]; +} + +// Which kind of library thing a call is about. EasyEDA's own enum, read +// from the runtime rather than copied, for the same reason the layer +// ids are: a table here would be a second copy of their numbering. +const LIBRARY_KINDS = ['CBB', 'SYMBOL', 'DEVICE', 'FOOTPRINT', 'MODEL', + 'PANEL_LIBRARY']; + +function libraryKind(params) { + const name = String(params.kind || 'SYMBOL').toUpperCase(); + if (!LIBRARY_KINDS.includes(name)) { + throw new Error(`kind must be one of: ${LIBRARY_KINDS.join(', ')}`); + } + const kinds = injectedEnum('ELIB_LibraryType'); + const value = kinds[name]; + if (value === undefined) { + throw new Error( + `ELIB_LibraryType has no member ${name} in this runtime`); + } + return value; +} + +// ---- creating documents --------------------------------------------- +// +// The from-scratch path: a project, then a schematic and a board inside +// it. Without these the backend can only work on something a human made +// first, which is the difference between editing a design and authoring +// one. + +handlers['proj.create_schematic'] = async (params) => { + const uuid = await eda.dmt_Schematic.createSchematic( + typeof params.name === 'string' && params.name ? params.name : undefined); + return { uuid: uuid || null, created: Boolean(uuid) }; +}; + +handlers['proj.create_schematic_page'] = async (params) => { + if (typeof params.uuid !== 'string' || !params.uuid) { + throw new Error('uuid of the schematic is required'); + } + const uuid = await eda.dmt_Schematic.createSchematicPage(params.uuid); + return { uuid: uuid || null, created: Boolean(uuid) }; +}; + +handlers['proj.create_pcb'] = async (params) => { + const uuid = await eda.dmt_Pcb.createPcb( + typeof params.name === 'string' && params.name ? params.name : undefined); + return { uuid: uuid || null, created: Boolean(uuid) }; +}; + +handlers['sch.set_title_block'] = async (params) => { + const fields = params.fields; + const show = params.show !== false; + if (fields !== undefined + && (typeof fields !== 'object' || fields === null + || Array.isArray(fields))) { + throw new Error('fields must be an object of {name: {value}}'); + } + const applied = await eda.dmt_Schematic.modifySchematicPageTitleBlock( + show, fields || undefined); + return { applied: applied === true, show }; +}; + +handlers['sys.workspaces'] = async () => ({ + workspaces: (await eda.dmt_Workspace.getAllWorkspacesInfo()) || [], + current: (await eda.dmt_Workspace.getCurrentWorkspaceInfo()) || null, +}); + +// The whole active document, as text. This is what makes a checkpoint +// possible on a backend with no filesystem the server can reach: the +// document travels as a string rather than as a path. +handlers['sys.document_source'] = async () => ({ + source: await eda.sys_FileManager.getDocumentSource(), + document: await currentDocumentKind(), + name: await boardName(), +}); + +handlers['sys.set_document_source'] = async (params) => { + if (typeof params.source !== 'string' || !params.source) { + throw new Error('source is required'); + } + if (params.confirm !== true) { + throw new Error( + 'set_document_source REPLACES the whole open document. Pass ' + + 'confirm=true if that is intended.', + ); + } + // Returns false on a source it cannot parse, which is a refusal + // rather than a throw, so it is reported as one. + const applied = await eda.sys_FileManager.setDocumentSource(params.source); + return { restored: applied === true }; +}; + +handlers['lib.classifications'] = async (params) => { + const tree = await eda.lib_Classification.getAllClassificationTree( + libraryUuidOf(params), libraryKind(params)); + return { classifications: tree || [] }; +}; + +handlers['lib.open_symbol'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + const opened = await eda.lib_Symbol.openInEditor(uuid, libraryUuid); + return { opened: opened || null }; +}; + +handlers['lib.open_footprint'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + const opened = await eda.lib_Footprint.openInEditor(uuid, libraryUuid); + return { opened: opened || null }; +}; + +handlers['lib.modify_symbol'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + if (!params.name && !params.description) { + throw new Error( + 'give a name or a description; an empty change reports success ' + + 'while doing nothing', + ); + } + return { + modified: await eda.lib_Symbol.modify( + uuid, libraryUuid, + typeof params.name === 'string' && params.name + ? params.name : undefined, + undefined, + typeof params.description === 'string' && params.description + ? params.description : undefined) === true, + }; +}; + +handlers['lib.modify_footprint'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + if (!params.name && !params.description) { + throw new Error( + 'give a name or a description; an empty change reports success ' + + 'while doing nothing', + ); + } + return { + modified: await eda.lib_Footprint.modify( + uuid, libraryUuid, + typeof params.name === 'string' && params.name + ? params.name : undefined, + undefined, + typeof params.description === 'string' && params.description + ? params.description : undefined) === true, + }; +}; + +handlers['lib.get_device'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + const device = (await eda.lib_Device.get(uuid, libraryUuid)) || null; + if (!device) return { device: null, model_3d: null, model_3d_source: 'absent' }; + + // lib_Device.get DROPS THE 3D MODEL. Measured on one uuid: search + // reports model3DUuid and model3DName for it, and get returns an + // association holding only symbol, footprint and images. So a caller + // reading get concludes the part has no 3D model, which is a false + // negative rather than a missing field, and an audit built on it + // would report every device as unmodelled. + // + // Backfilled from search and matched on uuid. Searches cap at ten, so + // a common name can hide the row: that case is reported as + // UNRESOLVED rather than as absent, because "we could not see it" and + // "it is not there" call for different next steps. + const assoc = device.association || {}; + if (assoc.model3D || assoc.model3DUuid) { + return { + device: device, + model_3d: assoc.model3D || { uuid: assoc.model3DUuid }, + model_3d_source: 'get', + }; + } + if (!device.name) { + return { device: device, model_3d: null, model_3d_source: 'unresolved' }; + } + try { + const rows = (await eda.lib_Device.search(device.name)) || []; + const row = rows.find((r) => r && r.uuid === uuid); + if (!row) { + return { device: device, model_3d: null, model_3d_source: 'unresolved' }; + } + return { + device: device, + model_3d: row.model3DUuid + ? { uuid: row.model3DUuid, name: row.model3DName || null } + : null, + model_3d_source: 'search', + }; + } catch (e) { + return { device: device, model_3d: null, model_3d_source: 'unresolved' }; + } +}; + +handlers['lib.copy_device'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + if (typeof params.target_library_uuid !== 'string' + || !params.target_library_uuid) { + throw new Error('target_library_uuid is required'); + } + const created = await eda.lib_Device.copy( + uuid, libraryUuid, params.target_library_uuid, undefined, + typeof params.new_name === 'string' && params.new_name + ? params.new_name : undefined); + return { uuid: created || null, copied: Boolean(created) }; +}; + +handlers['lib.delete_symbol'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + if (params.confirm !== true) { + throw new Error( + 'delete_symbol removes the drawing from the library. Pass ' + + 'confirm=true if that is intended.', + ); + } + return { + deleted: await eda.lib_Symbol.delete(uuid, libraryUuid) === true, + }; +}; + +handlers['lib.delete_footprint'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + if (params.confirm !== true) { + throw new Error( + 'delete_footprint removes the land pattern from the library. ' + + 'Pass confirm=true if that is intended.', + ); + } + return { + deleted: await eda.lib_Footprint.delete(uuid, libraryUuid) === true, + }; +}; + +handlers['lib.modify_device'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + if (!params.name && !params.description) { + throw new Error( + 'give a name or a description; an empty change reports success ' + + 'while doing nothing', + ); + } + return { + modified: await eda.lib_Device.modify( + uuid, libraryUuid, + typeof params.name === 'string' && params.name + ? params.name : undefined, + undefined, + typeof params.description === 'string' && params.description + ? params.description : undefined) === true, + }; +}; + +handlers['lib.delete_device'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + if (params.confirm !== true) { + throw new Error( + 'delete_device removes the part from the library. Pass ' + + 'confirm=true if that is intended.', + ); + } + return { + deleted: await eda.lib_Device.delete(uuid, libraryUuid) === true, + }; +}; + +// ---- removing documents and projects -------------------------------- +// +// dmt_Schematic.deleteSchematic and deleteSchematicPage, dmt_Pcb +// .deletePcb and dmt_Project.deleteProject all exist, which is what +// makes these handlers possible. Four other project-level operations +// (annotate, variant management, replace-component and project +// parameters) have no method on any class, so they are unavailable +// rather than merely unwritten. +// +// Every one is destructive and refuses without confirm, matching the +// library deletes. The result is read rather than assumed: these +// answer falsey when the editor declines, exactly as modify does. + +function requireConfirm(params, what) { + if (params.confirm !== true) { + throw new Error( + `${what} Pass confirm=true if that is intended.`); + } +} + +handlers['proj.delete_schematic'] = async (params) => { + if (!params.uuid) throw new Error('uuid is required'); + requireConfirm(params, 'delete_schematic removes the schematic and ' + + 'every page in it.'); + return { + deleted: await eda.dmt_Schematic.deleteSchematic(params.uuid) === true, + }; +}; + +handlers['proj.delete_schematic_page'] = async (params) => { + if (!params.uuid) throw new Error('uuid is required'); + requireConfirm(params, 'delete_schematic_page removes the page and ' + + 'everything drawn on it.'); + return { + deleted: + await eda.dmt_Schematic.deleteSchematicPage(params.uuid) === true, + }; +}; + +handlers['proj.delete_pcb'] = async (params) => { + if (!params.uuid) throw new Error('uuid is required'); + requireConfirm(params, 'delete_pcb removes the board, including its ' + + 'routing.'); + return { deleted: await eda.dmt_Pcb.deletePcb(params.uuid) === true }; +}; + +handlers['proj.delete_project'] = async (params) => { + if (!params.uuid) throw new Error('uuid is required'); + requireConfirm(params, 'delete_project removes the WHOLE project: ' + + 'every schematic, every board and the library items stored in it.'); + return { + deleted: await eda.dmt_Project.deleteProject(params.uuid) === true, + }; +}; + +handlers['editor.close_document'] = async (params) => { + if (!params.uuid) throw new Error('uuid is required'); + // Closing is not destructive: nothing is deleted and an unsaved + // document is the editor's business, so no confirm here. + const answer = await eda.dmt_EditorControl.closeDocument(params.uuid); + if (answer === false) { + return { uuid: params.uuid, closed: false, + failed: 'the editor declined to close that document' }; + } + return { uuid: params.uuid, closed: true }; +}; + +handlers['lib.copy_symbol'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + if (typeof params.target_library_uuid !== 'string' + || !params.target_library_uuid) { + throw new Error('target_library_uuid is required'); + } + const created = await eda.lib_Symbol.copy( + uuid, libraryUuid, params.target_library_uuid, undefined, + typeof params.new_name === 'string' && params.new_name + ? params.new_name : undefined); + return { uuid: created || null, copied: Boolean(created) }; +}; + +handlers['lib.copy_footprint'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + if (typeof params.target_library_uuid !== 'string' + || !params.target_library_uuid) { + throw new Error('target_library_uuid is required'); + } + const created = await eda.lib_Footprint.copy( + uuid, libraryUuid, params.target_library_uuid, undefined, + typeof params.new_name === 'string' && params.new_name + ? params.new_name : undefined); + return { uuid: created || null, copied: Boolean(created) }; +}; + +handlers['lib.list_libraries'] = async () => { + // getAllLibrariesList RETURNS AN EMPTY ARRAY, measured against a live + // editor holding a populated system library. Reporting that as the + // answer says there are no libraries, which is a different claim from + // "the enumeration is not implemented" and sends a caller looking for + // a workspace problem that does not exist. + // + // The four named getters do answer, so the uuids a search can be + // scoped to are reachable even though listing them is not. + const enumerated = (await eda.lib_LibrariesList.getAllLibrariesList()) || []; + const named = {}; + const getters = { + system: 'getSystemLibraryUuid', + personal: 'getPersonalLibraryUuid', + project: 'getProjectLibraryUuid', + favorite: 'getFavoriteLibraryUuid', + }; + for (const key of Object.keys(getters)) { + try { + named[key] = (await eda.lib_LibrariesList[getters[key]]()) || null; + } catch (e) { named[key] = null; } + } + return { + libraries: enumerated, + enumeration_empty: enumerated.length === 0, + known_library_uuids: named, + }; +}; + +// EasyEDA caps every library search at ten results and exposes no way +// past it. A numeric second argument matches nothing and an object one +// never returns, so ten is the entire answer rather than the first page +// of one. A caller choosing between parts has no route to the eleventh, +// and a reply that does not say so reads as the complete set. +const LIB_SEARCH_CAP = 10; + +// The second argument scopes the search to one library, measured: the +// system uuid returns the same ten, and the personal, project and +// favorite uuids return none for a term the system library matches. +// +// Only lib_Symbol refuses an empty query, and it refuses by NEVER +// ANSWERING rather than by throwing, so the guard there protects the +// connection. The other three return a default page for an empty +// query, and guarding them invented a restriction the editor does not +// have while advertising the parameter as optional. +async function librarySearch(className, params, allowEmpty) { + const query = params.query || ''; + if (!query && !allowEmpty) { + throw new Error( + 'query is required: ' + className + '.search does not answer an ' + + 'empty query, and the call hangs rather than being refused'); + } + const libraryUuid = params.library_uuid || ''; + const instance = eda[className]; + const found = (libraryUuid + ? await instance.search(query, libraryUuid) + : await instance.search(query)) || []; + return { + found: found, + meta: { + result_count: found.length, + result_cap: LIB_SEARCH_CAP, + // At the cap there are probably more, and no argument reaches + // them. Saying so is the difference between a bound and a silent + // one. + capped: found.length >= LIB_SEARCH_CAP, + library_uuid: libraryUuid || null, + query: query, + }, + }; +} + +handlers['lib.search_devices'] = async (params) => { + const out = await librarySearch('lib_Device', params, true); + return Object.assign({ devices: out.found }, out.meta); +}; + +handlers['lib.devices_by_lcsc'] = async (params) => { + const ids = params.lcsc_ids; + if (!Array.isArray(ids) || ids.length === 0) { + throw new Error('lcsc_ids must be a non-empty array'); + } + return { devices: (await eda.lib_Device.getByLcscIds(ids)) || [] }; +}; + +handlers['lib.search_symbols'] = async (params) => { + // The one class that hangs on an empty query, so the one that keeps + // the guard. + const out = await librarySearch('lib_Symbol', params, false); + return Object.assign({ symbols: out.found }, out.meta); +}; + +handlers['lib.search_footprints'] = async (params) => { + const out = await librarySearch('lib_Footprint', params, true); + return Object.assign({ footprints: out.found }, out.meta); +}; + +handlers['lib.symbol_image'] = async (params) => { + const uuid = params.uuid; + if (!uuid) throw new Error('uuid is required'); + // A picture is the only way to catch geometry that scores well and + // looks wrong, which is a recurring failure in this project's own + // library work. + return { image: await eda.lib_Symbol.getRenderImage(uuid) }; +}; + +handlers['lib.footprint_image'] = async (params) => { + const uuid = params.uuid; + if (!uuid) throw new Error('uuid is required'); + return { image: await eda.lib_Footprint.getRenderImage(uuid) }; +}; + +handlers['proj.list'] = async () => ({ + project_uuids: (await eda.dmt_Project.getAllProjectsUuid()) || [], +}); + +handlers['proj.get'] = async (params) => { + if (typeof params.uuid !== 'string' || !params.uuid) { + throw new Error('uuid is required'); + } + return { project: (await eda.dmt_Project.getProjectInfo(params.uuid)) + || null }; +}; + +handlers['proj.open'] = async (params) => { + if (typeof params.uuid !== 'string' || !params.uuid) { + throw new Error('uuid is required'); + } + return { opened: await eda.dmt_Project.openProject(params.uuid) === true }; +}; + +handlers['proj.create'] = async (params) => { + if (typeof params.name !== 'string' || !params.name) { + throw new Error('name is required'); + } + const uuid = await eda.dmt_Project.createProject( + params.name, + typeof params.internal_name === 'string' && params.internal_name + ? params.internal_name : undefined, + typeof params.team_uuid === 'string' && params.team_uuid + ? params.team_uuid : undefined, + typeof params.folder_uuid === 'string' && params.folder_uuid + ? params.folder_uuid : undefined, + typeof params.description === 'string' && params.description + ? params.description : undefined, + ); + return { uuid: uuid || null, created: Boolean(uuid) }; +}; + +handlers['proj.info'] = async () => ({ + project: (await eda.dmt_Project.getCurrentProjectInfo()) || null, +}); + +handlers['sch.list_schematics'] = async () => ({ + schematics: (await eda.dmt_Schematic.getAllSchematicsInfo()) || [], +}); + +handlers['sch.list_pages'] = async () => ({ + pages: + (await eda.dmt_Schematic.getCurrentSchematicAllSchematicPagesInfo()) || [], +}); + +// Pins placed directly on a document, which is what a SYMBOL holds. +// A schematic's part pins are not here; those come from the netlist. +handlers['sch.pins'] = async () => ({ + pins: (await eda.sch_PrimitivePin.getAll()) || [], +}); + +handlers['sch.assembly_variants'] = async () => ({ + variants: (await eda.sch_ManufactureData.getAssemblyVariantsConfigs()) || [], +}); + +handlers['export.sch_bom'] = async () => ({ + file: await packedFile(await eda.sch_ManufactureData.getBomFile()), +}); + +handlers['export.simulation_netlist'] = async () => ({ + file: await packedFile(await eda.sch_ManufactureData.getSimulationNetlistFile()), +}); + +handlers['editor.render_image'] = async () => { + // The only way to see what the board actually looks like. This + // project's own experience is that geometry can score well and look + // wrong, and no numeric check substitutes for looking. + // + // PACKED, like every other binary the editor hands back. This + // returned the raw value, and the raw value is a Blob: + // JSON.stringify(blob) is {}, so the whole reply serialised to an + // empty object and the image vanished on the way out. The tool then + // reported success with nothing in it, which is the worst outcome + // for the one check that exists to make somebody LOOK. + const packed = await packedFile( + await eda.dmt_EditorControl.getCurrentRenderedAreaImage()); + if (packed === null) { + return { + rendered: false, + failed: 'the editor returned no image. Nothing was rendered, so ' + + 'this is not a picture of an empty board.', + }; + } + return { rendered: true, image: packed }; +}; + +handlers['pcb.selection'] = async () => ({ + selected: (await eda.pcb_SelectControl.getAllSelectedPrimitives()) || [], +}); + +handlers['pcb.clear_selection'] = async () => { + await eda.pcb_SelectControl.clearSelected(); + return { cleared: true }; +}; + +handlers['pcb.cross_probe'] = async (params) => { + const ids = params.primitive_ids; + if (!Array.isArray(ids) || ids.length === 0) { + throw new Error('primitive_ids must be a non-empty array'); + } + await eda.pcb_SelectControl.doCrossProbeSelect(ids); + return { selected: ids.length }; +}; + +handlers['pcb.modify_component'] = async (params) => { + const { primitive_id, changes } = params; + if (!primitive_id) throw new Error('primitive_id is required'); + if (!changes || typeof changes !== 'object' || + Object.keys(changes).length === 0) { + throw new Error( + 'changes must name at least one property; an empty change would ' + + 'report success while doing nothing', + ); + } + // The RESULT is read, not discarded. + // + // modify answers falsey when the editor will not make the change; it + // does not throw. Ignoring that and reporting `changed` from the keys + // we ASKED for told the caller the component had moved when it had + // not, and the only way to find out otherwise was to look at the + // board. Measured against a declining fake: this + // returned {"primitive_id":"P1","changed":["x","y"]} for a change + // that never happened. + const applied = await eda.pcb_PrimitiveComponent.modify( + primitive_id, changes); + if (applied === false || applied === null || applied === undefined) { + return { + primitive_id: primitive_id, + modified: 0, + requested: Object.keys(changes), + failed: 'the editor declined the change and it was NOT applied', + }; + } + return { + primitive_id: primitive_id, + modified: 1, + changed: Object.keys(changes), + }; +}; + +handlers['pcb.arcs'] = async () => ({ + arcs: (await eda.pcb_PrimitiveArc.getAll()) || [], +}); + +handlers['pcb.regions'] = async () => ({ + regions: (await eda.pcb_PrimitiveRegion.getAll()) || [], +}); + +handlers['sch.wires'] = async () => ({ + wires: (await eda.sch_PrimitiveWire.getAll()) || [], +}); + +//: Read a collection the fast way, and the other way if that stalls. +//: +//: Measured: sch.attributes, pcb.attributes, pcb.strings +//: and pcb.poured each accepted a getAll() and never answered. Between +//: them they block three board audits and one library check, because +//: the data simply never arrives. WHY they hang is not established and +//: may be the editor's business rather than ours. +//: +//: Every one of those classes also offers getAllPrimitiveId() and +//: get(id), which is a second route to the same rows. Whether that +//: route survives when getAll() does not cannot be answered from this +//: side, because the hang lives in the editor and nothing here can +//: reproduce it. So both are tried and the answer carries WHICH ONE +//: replied, which is the part that settles the question on the next +//: live run rather than after another round of guessing. +//: +//: getAll keeps a short budget of its own rather than the dispatcher's +//: full ceiling. A fallback that waited for the outer timeout would +//: never run: the dispatcher ends the whole command at that point. +const FAST_READ_MS = 4000; + +//: Shape a readAll result as a handler reply: the rows under the name +//: the command has always used, plus the route that produced them. +//: Kept in one place so the four callers cannot drift into reporting +//: the route three different ways, which is how an aggregate ends up +//: unable to read its own inputs. +function withRoute(key, read) { + const out = {}; + out[key] = read.rows; + out.via = read.via; + if (read.ids_seen !== undefined) out.ids_seen = read.ids_seen; + if (read.getall_failed !== undefined) out.getall_failed = read.getall_failed; + if (read.unreadable !== undefined) out.unreadable = read.unreadable; + return out; +} + +async function readAll(api, name) { + let timer = null; + try { + const rows = await Promise.race([ + api.getAll(), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${name}.getAll did not answer in ` + + `${FAST_READ_MS}ms`)), FAST_READ_MS); + }), + ]).finally(() => { if (timer !== null) clearTimeout(timer); }); + // An empty collection is a real answer: a board with no such + // primitives reads zero. Treating that as a failure would make + // every clean board pay for the per-item path and would report the + // wrong route for a call that worked. + return { rows: rows || [], via: 'getAll' }; + } catch (e) { + // Fall through. The reason is kept so a caller can tell a stall + // from a refusal, which call for different next steps. + var why = String((e && e.message) || e); + } + + const ids = (await api.getAllPrimitiveId()) || []; + const rows = []; + for (let i = 0; i < ids.length; i += 1) { + // One bad id must not lose the rest of the collection: a partial + // read that says so beats no read at all. + try { + const row = await api.get(ids[i]); + if (row) rows.push(row); + } catch (inner) { /* counted below by the shortfall */ } + } + const out = { rows: rows, via: 'ids', ids_seen: ids.length, + getall_failed: why }; + if (rows.length !== ids.length) { + out.unreadable = ids.length - rows.length; + } + return out; +} + +handlers['pcb.attributes'] = async () => withRoute('attributes', + await readAll(eda.pcb_PrimitiveAttribute, 'pcb_PrimitiveAttribute')); + +handlers['sch.attributes'] = async () => withRoute('attributes', + await readAll(eda.sch_PrimitiveAttribute, 'sch_PrimitiveAttribute')); + +handlers['pcb.dimensions'] = async () => ({ + dimensions: (await eda.pcb_PrimitiveDimension.getAll()) || [], +}); + +handlers['sch.create_net_label'] = async (params) => { + // A label is placed AT a point, so the coordinates are not optional + // decoration. An earlier version of this passed the net name as the + // first argument, which is x: the label went nowhere and the net + // stayed unconnected, with nothing in the reply saying so. + const name = params.name; + if (!name) throw new Error('name is required'); + requireNumbers(params, ['x', 'y']); + const label = await eda.sch_PrimitiveAttribute.createNetLabel( + params.x, params.y, name); + return { name, created: label || null }; +}; + +handlers['lib.search_3d_models'] = async (params) => { + const out = await librarySearch('lib_3DModel', params, true); + return Object.assign({ models: out.found }, out.meta); +}; + +handlers['sys.paths'] = async () => ({ + // Where the editor keeps things, reported rather than assumed. The + // extension runs sandboxed, so the server cannot infer these and a + // guessed path is how an export lands somewhere nobody looks. + eda: await eda.sys_FileSystem.getEdaPath(), + documents: await eda.sys_FileSystem.getDocumentsPath(), + projects: await eda.sys_FileSystem.getProjectsPaths(), + libraries: await eda.sys_FileSystem.getLibrariesPaths(), +}); + +// Switching tabs and framing the view. +// +// Seven dmt_EditorControl methods were exposed nowhere. These three are +// the ones whose call shape follows something this file already does: +// activateDocument takes a uuid exactly as openDocument below does, and +// the two zooms take nothing at all. +// +// The rest are left alone deliberately. generateIndicatorMarkers and +// removeIndicatorMarkers are the interesting pair, being EasyEDA's way +// to mark primitives in the editor the way an Altium review highlights +// violations, but their arguments are not known and inventing a +// signature for a call that draws on somebody's board is not a guess +// worth making offline. +handlers['editor.activate_document'] = async (params) => { + const uuid = params.uuid; + if (!uuid) throw new Error('uuid is required'); + // Read the answer: this declines by returning false rather than + // throwing, and reporting an unmade switch as made would send every + // following command to the wrong document. + const answer = await eda.dmt_EditorControl.activateDocument(uuid); + if (answer === false) { + return { uuid: uuid, activated: false, + failed: 'the editor declined to switch to that document' }; + } + return { uuid: uuid, activated: true }; +}; + +handlers['editor.zoom_to_all'] = async () => ({ + zoomed: await eda.dmt_EditorControl.zoomToAllPrimitives() !== false, +}); + +handlers['editor.zoom_to_selection'] = async () => ({ + zoomed: await eda.dmt_EditorControl.zoomToSelectedPrimitives() !== false, +}); + +handlers['editor.open_document'] = async (params) => { + const uuid = params.uuid; + if (!uuid) throw new Error('uuid is required'); + const answer = await eda.dmt_EditorControl.openDocument(uuid); + if (answer === false) { + return { uuid: uuid, opened: false, + failed: 'the editor declined to open that document' }; + } + + // Wait for the document to become readable, not merely opened. + // + // openDocument resolves before the document can answer reads, so a + // read issued immediately afterwards times out while the same read a + // moment later succeeds. That is indistinguishable from a broken + // read, and any caller that switches documents hits it repeatedly. + // + // Readiness means the document reports a kind, which is the cheapest + // question requiring it to be loaded. The wait is bounded and its + // outcome reported: opened but not readable is a distinct state and + // must not be returned as a plain success. + const READY_TRIES = 20; + const READY_GAP_MS = 250; + let kind = 'unknown'; + for (let i = 0; i < READY_TRIES; i += 1) { + try { + kind = await currentDocumentKind(); + } catch (e) { + kind = 'unknown'; + } + if (kind === 'pcb' || kind === 'schematic') break; + await delay(READY_GAP_MS); + } + if (kind !== 'pcb' && kind !== 'schematic') { + return { uuid: uuid, opened: true, ready: false, document: kind, + failed: `the document opened but did not become readable within ` + + `${READY_TRIES * READY_GAP_MS}ms; a read now may hang` }; + } + return { uuid: uuid, opened: true, ready: true, document: kind }; +}; + +// PCB_PrimitiveString, not PrimitiveText. Text on copper and silk is +// what silkscreen audits read, and the class name is easy to guess +// wrong: there is no PCB_PrimitiveText. +handlers['pcb.strings'] = async () => withRoute('strings', + await readAll(eda.pcb_PrimitiveString, 'pcb_PrimitiveString')); + +handlers['pcb.pours'] = async () => ({ + // The pour OUTLINE the user drew. Distinct from pcb.regions, and + // distinct again from the poured copper below. + pours: (await eda.pcb_PrimitivePour.getAll()) || [], +}); + +// The copper actually filled in after pouring. A pour whose outline +// exists but which has never been poured leaves no copper, and the two +// lists disagreeing is exactly that case. +handlers['pcb.poured'] = async () => withRoute('poured', + await readAll(eda.pcb_PrimitivePoured, 'pcb_PrimitivePoured')); + +handlers['pcb.fills'] = async () => ({ + fills: (await eda.pcb_PrimitiveFill.getAll()) || [], +}); + +handlers['sch.buses'] = async () => ({ + buses: (await eda.sch_PrimitiveBus.getAll()) || [], +}); + +handlers['sch.save'] = async () => { + const answer = await eda.sch_Document.save(); + if (answer === false) { + return { saved: false, failed: 'the editor declined to save' }; + } + return { saved: true }; +}; + +handlers['pcb.images'] = async () => ({ + images: (await eda.pcb_PrimitiveImage.getAll()) || [], +}); + +// Not the same thing as an image, despite the name. PCB_PrimitiveObject +// holds BINARY EMBEDDED objects, the colour-silkscreen kind, whose +// payload travels as binary data rather than as geometry. Kept separate +// from pcb.images because merging them would report two different +// object kinds under one heading and hide which is which. +handlers['pcb.embedded_objects'] = async () => ({ + objects: (await eda.pcb_PrimitiveObject.getAll()) || [], +}); + +handlers['pcb.bboxes'] = async (params) => { + const ids = params.primitive_ids; + if (!Array.isArray(ids) || ids.length === 0) { + throw new Error('primitive_ids must be a non-empty array'); + } + // One box PER id, which the single-box call cannot give: it encloses + // everything it is handed, so a caller wanting each one separately + // would pay a round trip apiece. + // + // A read, so it does NOT stop at the first failure. A box that cannot + // be measured is reported as null and the rest are still returned; + // stopping would throw away the answers already gathered. + const boxes = []; + let measured = 0; + for (const id of ids) { + try { + const box = await eda.pcb_Primitive.getPrimitivesBBox([id]); + if (box) { + boxes.push({ primitive_id: id, bbox: box }); + measured += 1; + } else { + boxes.push({ primitive_id: id, bbox: null }); + } + } catch (e) { + boxes.push({ primitive_id: id, bbox: null, error: String(e) }); + } + } + return { boxes, measured, of: ids.length }; +}; + +handlers['pcb.bbox'] = async (params) => { + const ids = params.primitive_ids; + if (!Array.isArray(ids) || ids.length === 0) { + throw new Error('primitive_ids must be a non-empty array'); + } + // Signature checked against the reference: it takes an ARRAY of ids + // and returns {minX, minY, maxX, maxY}. Passing a bare id would look + // reasonable and return undefined. + const box = await eda.pcb_Primitive.getPrimitivesBBox(ids); + if (!box) throw new Error('no bounding box for those primitive ids'); + return { bbox: box, count: ids.length }; +}; + +handlers['sys.environment'] = async () => ({ + // Which EasyEDA this actually is. Pro, JLCEDA Pro and the private + // edition differ in what the API exposes, and offline mode changes + // what a library call can reach. Reporting it means a later failure + // can be attributed rather than guessed at. + version: await eda.sys_Environment.getEditorCurrentVersion(), + is_pro: await eda.sys_Environment.isEasyEDAProEdition(), + is_jlceda_pro: await eda.sys_Environment.isJLCEDAProEdition(), + is_client: await eda.sys_Environment.isClient(), + is_offline: await eda.sys_Environment.isOfflineMode(), +}); + +handlers['dmt.team'] = async () => ({ + team: (await eda.dmt_Team.getCurrentTeamInfo()) || null, +}); + +handlers['dmt.folders'] = async (params) => { + // Signatures verified against the installed api-types.d.ts, every + // one of them wanting the team uuid first. + if (typeof params.team_uuid !== 'string' || !params.team_uuid) { + throw new Error('team_uuid is required; read it from dmt.team'); + } + const uuids = (await eda.dmt_Folder.getAllFoldersUuid(params.team_uuid)) + || []; + const folders = []; + for (const uuid of uuids) { + try { + const info = await eda.dmt_Folder.getFolderInfo( + params.team_uuid, uuid); + folders.push(info || { uuid: uuid }); + } catch (e) { + folders.push({ uuid: uuid, error: String(e) }); + } + } + return { folders, count: folders.length }; +}; + +handlers['dmt.create_folder'] = async (params) => { + if (typeof params.name !== 'string' || !params.name) { + throw new Error('name is required'); + } + if (typeof params.team_uuid !== 'string' || !params.team_uuid) { + throw new Error('team_uuid is required; read it from dmt.team'); + } + const uuid = await eda.dmt_Folder.createFolder( + params.name, + params.team_uuid, + typeof params.parent_folder_uuid === 'string' && params.parent_folder_uuid + ? params.parent_folder_uuid : undefined, + typeof params.description === 'string' && params.description + ? params.description : undefined, + ); + return { uuid: uuid || null, created: Boolean(uuid) }; +}; + +handlers['dmt.move_project_to_folder'] = async (params) => { + if (typeof params.project_uuid !== 'string' || !params.project_uuid) { + throw new Error('project_uuid is required'); + } + const moved = await eda.dmt_Project.moveProjectToFolder( + params.project_uuid, + typeof params.folder_uuid === 'string' && params.folder_uuid + ? params.folder_uuid : undefined, + ); + return { moved: moved === true }; +}; + +handlers['dmt.boards'] = async () => ({ + boards: (await eda.dmt_Board.getAllBoardsInfo()) || [], +}); + +handlers['dmt.panels'] = async () => ({ + panels: (await eda.dmt_Panel.getAllPanelsInfo()) || [], +}); + +// Panel documents, which had a read and nothing else. +// +// dmt_Panel is method-for-method parallel to dmt_Pcb: copy, create, +// delete, getAll, getCurrent, get, modifyName. The create signature is +// the one the sibling classes already use here, createPcb(name) and +// createSchematic(name) returning a uuid, so it is a convention this +// file already depends on rather than a guess made for panels. +// +// What is NOT here is a way to put a board INTO a panel: dmt_Panel has +// no add, insert or place method, and neither does dmt_Board. So this +// creates and manages the document; arranging boards inside it is not +// something the extension API appears to expose, and none of these +// tools should be read as an equivalent to a step-and-repeat. +handlers['dmt.create_panel'] = async (params) => { + const uuid = await eda.dmt_Panel.createPanel( + typeof params.name === 'string' && params.name ? params.name : undefined); + return { uuid: uuid || null, created: Boolean(uuid) }; +}; + +handlers['dmt.current_panel'] = async () => { + const info = await eda.dmt_Panel.getCurrentPanelInfo(); + // No panel open is a legitimate answer and not a failure, so it is + // reported as such rather than thrown. + return { panel: info || null, open: Boolean(info) }; +}; + +handlers['dmt.panel_info'] = async (params) => { + if (!params.uuid) throw new Error('uuid is required'); + const info = await eda.dmt_Panel.getPanelInfo(params.uuid); + return { panel: info || null, found: Boolean(info) }; +}; + +handlers['dmt.rename_panel'] = async (params) => { + if (!params.uuid) throw new Error('uuid is required'); + if (!params.name) throw new Error('name is required'); + // Read the answer. These methods decline by returning falsey rather + // than raising, and six handlers once reported work they had not + // done because nobody looked at what came back. + const done = await eda.dmt_Panel.modifyPanelName(params.uuid, params.name); + return { renamed: done !== false, uuid: params.uuid }; +}; + +handlers['dmt.delete_panel'] = async (params) => { + if (!params.uuid) throw new Error('uuid is required'); + requireConfirm(params, 'delete_panel removes the panel document.'); + return { deleted: await eda.dmt_Panel.deletePanel(params.uuid) === true }; +}; + +// ---- transport ------------------------------------------------------ + +function explainFailure(error) { + // "Cannot read properties of undefined (reading 'getAll')" is what + // the editor says when the eda.* class a command needs is not present + // in the current context, and it names the METHOD rather than the + // missing class, so it reads like a bug in the caller. + // + // A live session can produce dozens of these in a row. + // Every one looked like a defect in this project and none was. The + // raw text is kept, because it is the real error, with the reading + // added after it. + const text = String((error && error.message) || error); + + if (/Cannot read properties of (?:undefined|null) \(reading /.test(text) + || /is not a function/.test(text)) { + return ( + `${text} -- this usually means the eda.* class or method this ` + + `command needs is not present in the current context rather ` + + `than that the command is wrong. EasyEDA injects a different ` + + `API surface depending on the open document. Call ` + + `system.capabilities to see what is actually available here.`); + } + return text; +} + +async function dispatch(raw) { + let request; + try { + request = JSON.parse(raw); + } catch (e) { + // Not addressed to us, or corrupt. Staying silent is right: there + // is no id to answer to. + return; + } + + const { id, command, params } = request || {}; + if (!id || !command) return; + + const handler = handlers[command]; + if (!handler) { + send({ + id: id, + error: `unknown command ${command}. Known: ${Object.keys(handlers).join(', ')}`, + }); + return; + } + + // Refuse a command whose API is not present in THIS runtime, before + // running it. + // + // Measured on a live schematic tab: of 90 read-only tools, 33 came + // back as "Cannot read properties of null (reading 'map')" and 14 + // never replied at all, costing 20 to 60 seconds each. Both were the + // same thing. EasyEDA injects its API per document type, so on a + // schematic every pcb_* class is missing; calling one either throws + // an opaque TypeError from somewhere inside a handler, or returns a + // promise that never settles. + // + // Neither failure tells the caller the useful fact, which is simply + // that the wrong document is in front. Checking first turns both into + // an instant, specific refusal, and turns a 60-second hang into a + // reply. The check is here rather than in each handler because there + // are 161 of them and one that forgets is one that hangs. + const missing = await wrongDocumentFor(command); + if (missing) { + send({ id: id, error: missing }); + return; + } + + try { + // Always ANSWER, even when the editor's own call never returns. + // + // Measured: sch.attributes, pcb.attributes, + // pcb.strings, pcb.poured, sys.paths and sch.selection never + // replied, costing 20 to 60 seconds each while the caller waited on + // a socket that would stay quiet forever. Why they hang is not + // established and may be EasyEDA's business rather than ours, but + // the cost of not knowing is a dead session, and a reply saying "no + // answer in 15s" is actionable where silence is not. + // + // The handler is not cancelled: nothing here can stop a promise + // that will not settle. What changes is that the caller stops + // waiting on it, so one bad command no longer eats a whole run. + // That is why the message says the command was NOT refused; a hung + // WRITE may have completed, and reporting it as refused would + // invite a caller to run it a second time. + // Exports get a longer ceiling than reads. + // + // The default is sized for a read, which answers in well under a + // second. Generating a PDF, a DXF or an IPC-2581 file renders the + // whole board and can legitimately take much longer, so the read + // ceiling would report a working export as a hang and there would + // be no way to tell that apart from a real one. + // Commands measured never to answer get a SHORT budget. + // + // Probed one class at a time against a live editor: nine of the + // eleven schematic primitive classes answer getAll in about a + // second, and two never return at all. The same two families fail + // on the PCB side, so this is the attribute and embedded-object + // accessors rather than anything about one document. + // + // Still ATTEMPTED, not refused. A later EasyEDA release may fix + // them, and a hard refusal here would hide that forever. What + // changes is the price of finding out: three seconds instead of + // fifteen, which matters because a review that touches several of + // these spends most of its time waiting for silence. + const NEVER_ANSWERED = [ + 'sch.attributes', 'pcb.attributes', 'sch.selection', + 'pcb.strings', 'pcb.poured', 'sys.paths', + // The two library render calls. Confirmed at the API level + // through the reflective shim: lib_Symbol.getRenderImage and + // lib_Footprint.getRenderImage never return, for a symbol uuid + // and a footprint uuid taken from a live search that had just + // succeeded, so the ids were good. + // + // Worth recording that they are a HANG and not the empty-object + // fault that editor.render_image had. That one returned a Blob + // which JSON dropped; these produce nothing to drop. Packing them + // would have fixed nothing. + 'lib.symbol_image', 'lib.footprint_image', + // Measured twice on a live schematic holding 111 parts: the call + // is accepted and never returns. Not a missing class, and not an + // empty project, since the same document answers sch.components + // and sch.netlist. Budgeted like the rest so the caller waits + // three seconds for the refusal instead of the full timeout. + 'sch.assembly_variants', + ]; + const budget = command.indexOf('export.') === 0 + ? EXPORT_TIMEOUT_MS + : (NEVER_ANSWERED.indexOf(command) !== -1 + ? Math.min(3000, HANDLER_TIMEOUT_MS) + : HANDLER_TIMEOUT_MS); + let timer = null; + const result = await Promise.race([ + handler(params || {}), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error( + `${command} did not answer within ${budget}ms. ` + + 'The editor accepted the call and never returned; the ' + + 'command was NOT refused and may still be running. ' + + 'Known to happen for the attribute, string and poured ' + + 'reads.')), + budget); + }), + // Without this every completed command leaves a live timer behind, + // so a long session accumulates one per call and, off the browser, + // the pending timers alone keep the process from exiting. + ]).finally(() => { if (timer !== null) clearTimeout(timer); }); + send({ id: id, result: result }); + } catch (e) { + // Answer with the failure rather than going quiet. A missing reply + // is indistinguishable from a hung editor at the other end. + send({ id: id, error: explainFailure(e) }); + } +} + +// Whether this command's namespace matches the document in front. +// +// Decided by document kind, not by class presence. Every one of the +// API classes is present in every runtime, including the pcb_* classes +// on a schematic tab: the API surface is uniform and it is the DATA +// that is missing, so pcb_PrimitivePad.getAll fails inside EasyEDA +// with "Cannot read properties of null" rather than being undefined. +// +// A probe for missing classes therefore never fires. It only +// ever worked against the Node fake, where classes genuinely are +// absent. Document kind is the real discriminator, which is the exact +// opposite of what the old comment here claimed. +// +// Only a POSITIVE mismatch refuses. `unknown` is left alone: it has +// been seen on a working editor, and refusing everything then would be +// worse than the failure being prevented. +async function wrongDocumentFor(command) { + // These answer whatever document is in front, because they reach + // project-level or net-level data rather than the open document's + // primitives. Refusing them by namespace would break tools that work + // correctly from either tab, trading a slow failure for a fast wrong + // answer. + const WORKS_ANYWHERE = [ + 'pcb.nets', 'pcb.net_length', 'pcb.net_lengths', 'pcb.list_boards', + // Listing the documents in a PROJECT is not reading a schematic's + // contents, and refusing it from a board tab is a trap: to find a + // schematic's uuid you must already be in a schematic. Measured + // that makes it impossible to navigate back from the + // PCB, which is the one direction anything automating a review + // needs. pcb.list_boards was exempt for exactly this reason and + // its schematic twin was not. + 'sch.list_schematics', 'sch.list_pages', + ]; + if (WORKS_ANYWHERE.indexOf(command) !== -1) return null; + + // Commands whose NAMESPACE does not say which document they need. + // Every one of these reaches a pcb_* or sch_* class, so the gate + // below could not see it and the command ran on whichever tab was + // in front. It then failed inside EasyEDA with a null dereference, + // which reads as a broken tool rather than as the wrong document. + // + // Each entry is taken from the class family the handler actually + // touches, not from its name. + const NEEDS_DOCUMENT = { + 'design.snapshot': 'pcb', + 'design.run_drc': 'pcb', + 'design.run_erc': 'schematic', + 'export.bom': 'pcb', + 'export.dxf': 'pcb', + 'export.model_3d': 'pcb', + 'export.gerber': 'pcb', + 'export.ipc2581': 'pcb', + 'export.ipcd356': 'pcb', + 'export.netlist': 'pcb', + 'export.altium': 'pcb', + 'export.pdf': 'pcb', + 'export.pick_and_place': 'pcb', + 'export.test_points': 'pcb', + 'export.flying_probe': 'pcb', + 'export.dsn': 'pcb', + 'export.pads': 'pcb', + 'export.pcb_info': 'pcb', + 'export.schematic_document': 'schematic', + 'export.schematic_netlist': 'schematic', + 'export.sch_bom': 'schematic', + 'export.simulation_netlist': 'schematic', + }; + + const namespace = String(command || '').split('.')[0]; + const needs = + NEEDS_DOCUMENT[command] || { pcb: 'pcb', sch: 'schematic' }[namespace]; + if (!needs) return null; + + let kind; + try { + kind = await currentDocumentKind(); + } catch (e) { + return null; // cannot tell; do not invent one + } + if (kind === needs) return null; + + const wanted = needs === 'pcb' ? 'a PCB' : 'a schematic'; + + // NOTHING OPEN IS A DEFINITE ANSWER, NOT AN UNKNOWN ONE. + // currentDocumentKind returns 'unknown' only after BOTH probes ran + // and neither found a document, so it means no PCB and no schematic + // is open rather than "could not tell". Letting commands through on + // that reading is what turns an empty editor into a confusing + // failure: sch.add_wire reached the editor and came back + // "create failed!", and sch.components came back with an untranslated + // Chinese error, neither of which says the obvious thing. + // + // A genuine cannot-tell is the THROW above, which still returns null + // rather than inventing a document kind. + if (kind !== 'pcb' && kind !== 'schematic') { + return ( + `${command} needs ${wanted} document and none is open. Neither ` + + `dmt_Pcb.getCurrentPcbInfo nor ` + + `dmt_Schematic.getCurrentSchematicInfo reported a document, so ` + + `the editor is on the start page or a document type this cannot ` + + `drive. Open ${wanted} and try again. Nothing was run, so this ` + + `is not evidence the command would fail.` + ); + } + // The class family, not the command's namespace. An export command + // reaches pcb_* classes while being called export.gerber, and naming + // "export_*" here would send a reader looking for something that + // does not exist. + const family = needs === 'pcb' ? 'pcb' : 'sch'; + return ( + `${command} needs ${wanted} document and the active one is a ` + + `${kind}. The ${family}_* classes exist in every runtime, so ` + + `this would not fail with "undefined": it fails inside EasyEDA ` + + `with a null, or does not answer at all. Open ${wanted} and ` + + `connect from there, or call system.capabilities to see what is ` + + `available in the current context. Nothing was run, so this is ` + + `not evidence the command would fail.` + ); +} + + +function send(payload) { + // A plain send, deliberately. + // + // Catching a throw here to detect a dropped socket does not work: a + // send on a dead socket does not throw in this runtime, so nothing + // is detected, and a throw for any other reason would clear the + // connected flag and cause the retry loop to tear down a healthy + // connection. + // + // Liveness is handled by the idle reattach below, which needs no + // signal from here. + eda.sys_WebSocket.send(WS_ID, JSON.stringify(payload)); +} + +// Exported names here must match the `registerFn` values in +// extension.json. EasyEDA resolves them by string at load time, so a +// rename on either side fails as a menu item that does nothing rather +// than as an error. +// Ports to look on, matching the convention EasyEDA's own bridge server +// uses. A fixed port has to be agreed by hand and silently fails when it +// is taken; scanning finds whichever one the server got. +const PORT_START = 49620; +const PORT_END = 49629; +const SERVICE_ID = 'eda-agent-bridge'; + +// How often to look again when nothing is there yet. THE POINT OF THIS: +// SYS_WebSocket.register() fails silently if nothing is listening at +// that instant and never tries again, so a correct extension and a +// correct server can sit side by side and never meet. Retrying is what +// makes the order of starting them stop mattering. +const RETRY_MS = 5000; +//: How long to wait for a port to report a connection when there is no +//: health probe to ask. Long enough for a loopback socket to open, short +//: enough that walking eleven dead ports stays under a second. +const PROBE_MS = 250; + +let retryTimer = null; +let connected = false; + +function candidatePorts() { + const ports = []; + for (let p = PORT_START; p <= PORT_END; p += 1) ports.push(p); + ports.push(8787); // the previous fixed default, still honoured + return ports; +} + +// Whether this runtime gives the extension a usable fetch. +// +// EasyEDA's own guidance is that standard browser APIs are forbidden in +// the extension's main process and that EDA-provided alternatives +// should be used instead. So fetch may simply not be there, and the +// /health probe below is a preference, not a requirement. +// +// This mattered: with discovery resting on fetch alone, a runtime +// without it finds nothing on every port and reports "no server found", +// which is the same message as the server genuinely being down. The +// extension would look correct and never connect. +function hasFetch() { + return typeof fetch === 'function'; +} + +// ---- timers ---------------------------------------------------------- +// +// EasyEDA publishes SYS_Timer as the EDA-provided replacement for the +// host timer functions, and says the host ones are not available to an +// extension's main process. So they are preferred here, with the host +// versions as a fallback. +// +// This is not a style choice. The retry loop is what makes starting +// order stop mattering, and it was armed with a bare setInterval inside +// connect(), which activate() calls at load. On a runtime without it, +// that throws while the module is initialising, so the extension does +// not merely fail to retry, it fails to LOAD, and no menu item appears +// to say so. +// +// SYS_Timer identifies timers by string rather than by handle, so the +// two kinds cannot be cleared the same way and the handle carries its +// own kind. +const RETRY_TIMER_ID = 'eda-agent-retry'; +let probeSerial = 0; + + +//: How long an idle link is trusted before it is reopened regardless. +//: +//: sys_WebSocket offers close, register and send and nothing else: no +//: readyState, no close callback, and no way to ask whether the socket +//: is open. A dropped connection therefore cannot be detected, so this +//: does not try. After this long without a message the link is closed +//: and reopened whether or not it was healthy. +//: +//: Reattaching to a working server costs one socket; staying attached +//: to a dead one costs the session, and the API supports no third +//: option. +//: +//: Counted in retry ticks. Twelve at five seconds gives a minute of +//: silence before reconnecting, long enough that an active session +//: never reattaches and short enough that a restarted server is picked +//: up without intervention. +const IDLE_REATTACH_TICKS = 12; +let idleTicks = 0; + +function startInterval(fn, ms) { + // ARM BOTH TIMERS, for the reason delay() does: preferring the + // editor's timer and falling back only when it is ABSENT does not + // cover a timer that exists and never fires. This one carries the + // whole reconnection loop, so if it is silent nothing ever notices a + // dropped link. + // + // The tick is rate limited below rather than here, so two sources + // firing does not make it run twice as often. + let edaArmed = false; + let hostId = null; + + if (eda.sys_Timer && typeof eda.sys_Timer.setIntervalTimer === 'function') { + try { + eda.sys_Timer.setIntervalTimer(RETRY_TIMER_ID, ms, fn); + edaArmed = true; + } catch (e) { /* the host timer below is the fallback */ } + } + if (typeof setInterval === 'function') { + hostId = setInterval(fn, ms); + } + + if (!edaArmed && hostId === null) { + // Neither is available. One connection attempt still happens; only + // the retry is lost, and saying so beats throwing at load. + return null; + } + return { + kind: edaArmed && hostId !== null ? 'both' + : (edaArmed ? 'eda' : 'host'), + id: edaArmed ? RETRY_TIMER_ID : null, + hostId: hostId, + }; +} + +function stopInterval(handle) { + if (!handle) return; + // Both may be armed, so clear both. Clearing only the one named by + // `kind` would leave the other firing after an explicit disconnect, + // which would reconnect the user straight back. + try { + if (handle.id !== null && handle.id !== undefined) { + eda.sys_Timer.clearIntervalTimer(handle.id); + } + } catch (e) { /* already gone */ } + try { + if (handle.hostId !== null && handle.hostId !== undefined) { + clearInterval(handle.hostId); + } + } catch (e) { /* already gone */ } +} + +function delay(ms) { + // ARM BOTH TIMERS AND TAKE WHICHEVER FIRES FIRST. + // + // Preferring the editor's timer and falling back only when it is + // ABSENT covers the wrong failure. A timer that exists and never + // fires leaves this promise pending forever, and everything awaiting + // it stops: the port walk stalls on its first candidate and the + // attach that owns it never finishes. Whether the editor's timer + // fires while the extension is idle is not established, so this stops + // depending on the answer. + // + // Resolving twice is harmless; a promise keeps its first settlement. + return new Promise((resolve) => { + let armed = false; + if (eda.sys_Timer + && typeof eda.sys_Timer.setTimeoutTimer === 'function') { + probeSerial += 1; + try { + eda.sys_Timer.setTimeoutTimer( + `${RETRY_TIMER_ID}-probe-${probeSerial}`, ms, resolve); + armed = true; + } catch (e) { /* fall through to the host timer */ } + } + if (typeof setTimeout === 'function') { + setTimeout(resolve, ms); + armed = true; + } + // No timer at all: do not hang. Resolving at once makes the port + // walk check `connected` immediately, which is a worse probe but a + // finite one. + if (!armed) resolve(); + }); +} + +async function findServerByHealth() { + // Ask each port who it is rather than assuming whatever answers is + // ours. Another service on the port would otherwise get a WebSocket + // handshake it never asked for. + for (const port of candidatePorts()) { + try { + // BOUNDED. A fetch with no timeout of its own is how the whole + // attach wedges: attach() holds `attaching` for its duration, so + // one probe that never settles means every later retry returns + // immediately and the extension never reconnects again. Measured: + // after the server restarted, nothing reattached for three + // minutes although the retry timer was still firing. + // + // Racing a delay is used rather than AbortSignal.timeout, which + // is not guaranteed present in this runtime. The probe is left to + // finish in the background; only the waiting is bounded. + const response = await Promise.race([ + fetch(`http://127.0.0.1:${port}/health`, { method: 'GET' }), + delay(PROBE_MS * 4).then(() => null), + ]); + if (!response || !response.ok) continue; + const body = await Promise.race([ + response.json(), + delay(PROBE_MS * 4).then(() => null), + ]); + if (body && body.service === SERVICE_ID) { + return `ws://127.0.0.1:${port}/eda`; + } + } catch (e) { + // Nothing listening there. Expected for most of the range. + } + } + return null; +} + +async function findServer() { + if (hasFetch()) { + const found = await findServerByHealth(); + if (found) return found; + } + // No fetch, or nothing answered /health. Fall back to the ports + // themselves: the caller opens a WebSocket to each in turn and keeps + // the one that connects. Less precise than asking who is listening, + // and the reason the probe is tried first, but a connection that + // works beats an identification that cannot be made. + return null; +} + +async function attach() { + attachAttempts += 1; + if (connected) return; + + // ONE ATTACH AT A TIME. + // + // A scan is slow: eleven candidate ports, each with a probe delay, + // behind fetch probes that have no timeout of their own. It routinely + // outlives the retry interval, and the retry tick called attach() + // again regardless, so two scans ran side by side. + // + // That is fatal rather than merely wasteful, because they share + // WS_ID. The second scan renews the id and closes the first scan's + // socket; the first then wakes from its delay, sees no connection, + // and closes what is now the SECOND scan's socket, including one that + // had just connected. Two overlapping scans destroy each other's + // sockets indefinitely, which looks exactly like a retry loop that + // runs forever and never attaches. + // AN IN-FLIGHT ATTACH EXPIRES. The guard below is correct while an + // attach is genuinely running, and fatal if one never finishes: + // `attaching` stays true, every retry returns here, and the extension + // never reconnects for the rest of the session. That is not + // hypothetical, it was measured after a server restart, and the + // `finally` that clears the flag is no protection because it does not + // run while an await is still pending. + // + // So the flag carries a deadline. Past it, a new attach proceeds and + // takes ownership; the stalled one is left to finish whenever it + // does, and cannot clear a flag it no longer owns. + const startedAt = Date.now(); + if (attaching && (startedAt - attachingSince) < ATTACH_STALL_MS) return; + attaching = true; + attachingSince = startedAt; + const myAttach = startedAt; + try { + const configured = + eda.sys_Storage && eda.sys_Storage.getExtensionUserConfig + ? eda.sys_Storage.getExtensionUserConfig('serverUrl') + : null; + const url = configured || (await findServer()); + + if (url) { + openSocket(url); + return; + } + + // Nothing identified itself, which on a runtime without fetch is + // the normal case rather than a failure. Try the ports directly and + // keep whichever connects. Each attempt is closed before the next, + // so a port that answers but is not us leaves no socket behind. + for (const port of candidatePorts()) { + if (connected) return; + // Close by the id THIS call registered. Closing WS_ID would close + // whatever the global points at, which is the other half of the + // race above. + const mine = openSocket(`ws://127.0.0.1:${port}/eda`); + // Give the socket a moment to report success. The connected + // callback is what sets `connected`, so this is the only way to + // tell a live port from a dead one without a health probe. + await delay(PROBE_MS); + if (connected) return; + try { + eda.sys_WebSocket.close(mine); + } catch (e) { /* nothing was open */ } + } + } finally { + if (attachingSince === myAttach) { + attaching = false; + } + } +} + +function toast(text) { + try { + eda.sys_Message.showToastMessage(text); + } catch (e) { /* nothing to show on runtimes without a UI */ } +} + +export function connect() { + // Announce IMMEDIATELY, before anything can fail. The absence of + // this toast after a click means the code running in the editor is + // not this build, which is exactly the ambiguity this removes: same + // uuid, same version, and the re-import was silently a no-op. + toast(`eda-agent ${BUILD_ID}: connecting...`); + + // Start from a clean slate every time, because nothing else can. + // + // register() takes no close or error callback (checked against the + // published signature: id, serviceUri, receiveMessageCallFn, + // connectedCallFn, protocols), so the extension is never told when + // the server at the other end goes away. `connected` stays true, and + // attach() begins with `if (connected) return`, so picking Connect + // again does nothing at all and the retry loop skips too. The menu + // item looks broken when the truth is that it thinks its work is + // already done. + // + // Closing first matters for a second reason. The register() remarks + // warn that re-registering an ID that is still ACTIVE silently + // ignores the new parameters, so a stale socket would swallow every + // later attempt to point at a different port. + connected = false; + try { + eda.sys_WebSocket.close(WS_ID); + } catch (e) { /* nothing was open, which is the usual case */ } + + // attach() is async and this call site cannot await it (EasyEDA + // invokes registerFn synchronously), so a throw inside would vanish + // as an unhandled rejection. That is a SILENT dead click, and it is + // the failure mode that could not be told apart from a stale build. + attach().catch((e) => { + toast(`eda-agent failed to connect: ${(e && e.message) || e}`); + }); + if (retryTimer === null) { + retryTimer = startInterval(() => { + // Two timer sources may be armed, so the body is rate limited to + // one run per interval. Without this the idle counter advances + // twice per period and the reattach window is half what + // idle_limit says it is, which would make the reported numbers + // lies. + const now = Date.now(); + if (now - lastTickAt < RETRY_MS * 0.75) return; + lastTickAt = now; + + if (connected) { + idleTicks += 1; + if (idleTicks >= IDLE_REATTACH_TICKS) { + // Long enough without a word. Whether the server went away + // or simply had nothing to say cannot be told apart here, so + // the cheap option is taken: drop it and reattach. + idleTicks = 0; + connected = false; + try { + eda.sys_WebSocket.close(WS_ID); + } catch (e) { /* already gone, which is the case in point */ } + } + } + if (!connected) { + attach().catch(() => { /* the first failure was already shown */ }); + } + }, RETRY_MS); + } +} + +// Returns the id it registered under, so a caller that needs to undo +// this closes ITS OWN socket rather than whatever WS_ID happens to hold +// by then. WS_ID is global and moves under any concurrent attach. +function openSocket(url) { + // Close the previous registration and take a new id before opening. + const previous = renewSocketId(); + const mine = WS_ID; + try { + eda.sys_WebSocket.close(previous); + } catch (e) { /* nothing was open under that id */ } + eda.sys_WebSocket.register( + WS_ID, + url, + (event) => { + // register() hands back a MessageEvent, not a string. Verified + // against the published signature: + // receiveMessageCallFn?: (event: MessageEvent) => void + // Calling String(event) yields "[object MessageEvent]", so every + // command would be silently discarded while the socket looked + // perfectly healthy. + // Anything arriving proves the link is alive, which is the only + // positive evidence this API provides. + idleTicks = 0; + const raw = + event && typeof event === 'object' && 'data' in event + ? event.data + : event; + dispatch(typeof raw === 'string' ? raw : String(raw)); + }, + () => { + connected = true; + eda.sys_Message.showToastMessage(`eda-agent connected: ${url}`); + }, + ); + return mine; +} + +export function disconnect() { + connected = false; + if (retryTimer !== null) { + stopInterval(retryTimer); + retryTimer = null; + } + try { + eda.sys_WebSocket.close(WS_ID); + } catch (e) { /* already closed */ } +} + + +// EasyEDA calls activate() on load; connecting immediately is what makes +// the bridge usable without a menu click, and the menu items remain for +// reconnecting after the server restarts. +export function activate() { + connect(); +} + +export function deactivate() { + disconnect(); +} diff --git a/scripts/easyeda_dev_push.py b/scripts/easyeda_dev_push.py new file mode 100644 index 0000000..5a1eff2 --- /dev/null +++ b/scripts/easyeda_dev_push.py @@ -0,0 +1,173 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Push the built .eext into a running EasyEDA Pro, no manual import. + +EasyEDA's own SDK (pro-api-sdk >= 1.4.0, ``npm run debug``) starts a +WebSocket server on port 59394 and the editor connects TO it; on +connection the server immediately sends the packaged extension as +base64 and the editor installs it in place. No handshake, no flags in +the SDK's half. Message shape, read from their build/dev.ts: + + {"type": "file", + "topic": "Dev Mode Extension Package Update", + "content": "", + "fileName": "_v.eext", + "fileMimeType": "application/octet-stream"} + +Whether the DESKTOP client dials that port spontaneously, or only under +a dev setting, is not documented. This script measures it: run it, and +it reports whether anything connected and what it sent. The manual +delete/import/restart cycle cost hours today; if the editor takes this +push, that cycle is gone. + +Run: python scripts/easyeda_dev_push.py [seconds] +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import pathlib +import socket +import struct +import sys +import threading +import time + +HERE = pathlib.Path(__file__).resolve().parents[1] / "extensions" / "easyeda" +PORT = 59394 +_WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + + +def _accept_websocket(conn: socket.socket) -> bool: + """Answer the HTTP upgrade. Returns False for a non-WS probe.""" + conn.settimeout(10) + raw = b"" + while b"\r\n\r\n" not in raw: + chunk = conn.recv(4096) + if not chunk: + return False + raw += chunk + head = raw.decode("latin-1") + key = None + for line in head.split("\r\n"): + if line.lower().startswith("sec-websocket-key:"): + key = line.split(":", 1)[1].strip() + if not key: + return False + accept = base64.b64encode( + hashlib.sha1((key + _WS_MAGIC).encode("ascii")).digest()).decode() + conn.sendall( + ("HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {accept}\r\n\r\n").encode("ascii")) + return True + + +def _send_text(conn: socket.socket, text: str) -> None: + payload = text.encode("utf-8") + length = len(payload) + if length < 126: + header = struct.pack("!BB", 0x81, length) + elif length < 65536: + header = struct.pack("!BBH", 0x81, 126, length) + else: + header = struct.pack("!BBQ", 0x81, 127, length) + conn.sendall(header + payload) + + +def _push_message() -> str: + manifest = json.loads( + (HERE / "extension.json").read_text(encoding="utf-8")) + package = (HERE / "eda-agent-bridge.eext").read_bytes() + return json.dumps({ + "type": "file", + "topic": "Dev Mode Extension Package Update", + "content": base64.b64encode(package).decode("ascii"), + "fileName": f"{manifest['name']}_v{manifest['version']}.eext", + "fileMimeType": "application/octet-stream", + }) + + +def main() -> int: + args = [a for a in sys.argv[1:] if a != "--probe"] + probe_only = "--probe" in sys.argv[1:] + wait_seconds = int(args[0]) if args else 300 + message = _push_message() + if probe_only: + print("PROBE ONLY: a connection will be reported and nothing " + "will be installed.") + print(f"Package staged: {len(message)} chars of JSON " + f"({(HERE / 'eda-agent-bridge.eext').stat().st_size} byte eext)") + + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", PORT)) + server.listen(4) + server.settimeout(1.0) + print(f"Dev-push server on ws://127.0.0.1:{PORT} for " + f"{wait_seconds}s. Waiting to see whether EasyEDA dials it...") + + pushed = 0 + deadline = time.time() + wait_seconds + + def _serve(conn: socket.socket, peer) -> None: + nonlocal pushed + try: + if not _accept_websocket(conn): + print(f" {peer}: connected but not a WebSocket upgrade") + return + if probe_only: + # Measure without installing. + # + # Whether the desktop client dials this port at all is + # the unknown worth settling first, and it is settled by + # the connection itself. Pushing is a separate decision: + # it writes an extension into somebody's editor, which + # is not a thing to do as a side effect of finding out + # whether a socket opens. + print(f" {peer}: WEBSOCKET CONNECTED. Probe only, so " + f"nothing was pushed. The editor DOES dial this " + f"port, which means dev-push is available and the " + f"manual import cycle is avoidable.") + pushed += 1 + return + print(f" {peer}: WEBSOCKET CONNECTED, pushing the package") + _send_text(conn, message) + pushed += 1 + # Keep the socket open briefly to catch any reply frames. + conn.settimeout(15) + try: + reply = conn.recv(4096) + if reply: + print(f" {peer}: client sent {len(reply)} bytes back") + except socket.timeout: + pass + except Exception as exc: # noqa: BLE001 + print(f" {peer}: {exc}") + finally: + conn.close() + + while time.time() < deadline: + try: + conn, peer = server.accept() + except socket.timeout: + continue + threading.Thread(target=_serve, args=(conn, peer), + daemon=True).start() + + server.close() + if pushed: + print(f"\nPushed the package {pushed} time(s). If the editor " + f"accepted it, the extension updated in place with no " + f"manual import.") + return 0 + print("\nNothing connected. The desktop client does not dial the " + "dev port spontaneously; the manual import cycle stands, or a " + "client-side dev setting is needed first.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/easyeda_smoke.py b/scripts/easyeda_smoke.py new file mode 100644 index 0000000..eef7f73 --- /dev/null +++ b/scripts/easyeda_smoke.py @@ -0,0 +1,721 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Exercise the EasyEDA command vocabulary against a live editor. + +Everything about this backend is verified except the one thing that +matters most: whether the editor actually answers these commands, and +in the shape the Python side reads. That cannot be established without +EasyEDA running, so it is established here rather than assumed. + +READ ONLY. Nothing in this script changes a design. The destructive +commands exist and are guarded, and a smoke test is the wrong place to +find out whether a guard works on someone's open board. + +The output is the point. A command that returns an empty list is NOT +reported as passing: on a board with parts, an empty component list +means the response shape was misread, which is the failure this whole +exercise is looking for. Empty results are called out separately so a +wrong shape cannot hide as a quiet success. + +Run with EasyEDA Pro open, a board loaded, and the extension installed: + + python scripts/easyeda_smoke.py +""" + +from __future__ import annotations + +import json +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from eda_agent.bridge.easyeda_bridge import ( # noqa: E402 + EasyEdaBridge, + EasyEdaNotReachableError, +) + +#: (command, params, what a healthy answer looks like). The third item +#: names the key whose emptiness would mean a misread shape rather than +#: an empty design. +PROBES: list[tuple[str, dict, str]] = [ + ("system.ping", {}, "pong"), + ("proj.info", {}, "project"), + ("pcb.list_boards", {}, "boards"), + ("pcb.components", {}, "components"), + ("pcb.nets", {}, "nets"), + ("pcb.layers", {}, "layers"), + ("pcb.pads", {}, "pads"), + ("pcb.vias", {}, "vias"), + ("pcb.lines", {}, "lines"), + ("pcb.arcs", {}, "arcs"), + ("pcb.regions", {}, "regions"), + ("pcb.attributes", {}, "attributes"), + ("pcb.dimensions", {}, "dimensions"), + ("pcb.net_classes", {}, "net_classes"), + ("pcb.differential_pairs", {}, "differential_pairs"), + ("pcb.selection", {}, "selected"), + ("sch.list_schematics", {}, "schematics"), + ("sch.list_pages", {}, "pages"), + ("sch.components", {}, "components"), + ("sch.pins", {}, "pins"), + ("sch.wires", {}, "wires"), + ("sch.attributes", {}, "attributes"), + ("sch.netlist", {}, "netlist"), + ("sch.assembly_variants", {}, "variants"), + ("sys.paths", {}, "projects"), + ("lib.list_libraries", {}, "libraries"), + ("pcb.strings", {}, "strings"), + ("pcb.fills", {}, "fills"), + ("pcb.images", {}, "images"), + ("pcb.embedded_objects", {}, "objects"), + ("pcb.pours", {}, "pours"), + ("pcb.poured", {}, "poured"), + ("pcb.net_rules", {}, "rules"), + ("pcb.net_lengths", {}, "lengths"), + ("pcb.rule_configurations", {}, "configurations"), + ("pcb.length_match_groups", {}, "groups"), + ("sch.buses", {}, "buses"), + ("sch.selection", {}, "primitives"), + ("dmt.team", {}, "team"), + ("dmt.boards", {}, "boards"), + ("dmt.panels", {}, "panels"), + ("dmt.current_panel", {}, "open"), + ("proj.list", {}, "project_uuids"), + ("sys.environment", {}, "version"), + ("sys.workspaces", {}, "workspaces"), + # The snapshot is the one that feeds every EDA-agnostic check, so a + # wrong shape here is the most expensive of all. + ("design.snapshot", {}, "parts"), +] + +#: Run last: they are slow, and a failure here is less informative than +#: a failure in the reads above. +SLOW_PROBES: list[tuple[str, dict, str]] = [ + ("design.run_drc", {}, "violations"), + ("design.run_erc", {}, "violations"), + # Every fabrication export. Still read-only, and each generates a + # whole file, so they run last and separately: a slow export failing + # says nothing about whether the design reads correctly. + ("export.gerber", {}, "file"), + ("export.bom", {}, "file"), + ("export.sch_bom", {}, "file"), + ("export.netlist", {}, "file"), + ("export.schematic_netlist", {}, "file"), + ("export.simulation_netlist", {}, "file"), + ("export.dxf", {}, "file"), + ("export.pdf", {}, "file"), + ("export.pick_and_place", {}, "file"), + ("export.test_points", {}, "file"), + ("export.flying_probe", {}, "file"), + ("export.dsn", {}, "file"), + ("export.pads", {}, "file"), + ("export.pcb_info", {}, "file"), + ("export.ipc2581", {}, "file"), + ("export.ipcd356", {}, "file"), + ("export.altium", {}, "file"), + ("export.model_3d", {}, "file"), + ("export.schematic_document", {}, "file"), +] + +#: Commands for which an EMPTY answer is an ordinary state of a real +#: board rather than a misread shape. Nothing is selected most of the +#: time; most boards carry no dimensions, images or panels; a board +#: with no equal-length groups is a board without length matching. An +#: empty answer from anything ELSE stays suspicious, because that is +#: exactly how the net_lengths field-name bug presented: a loaded board +#: reporting a clean empty list. +#: +#: Still NOT verified: an empty list proves the command answered, not +#: that its items have the shape the tools read. +MAY_BE_EMPTY: frozenset = frozenset({ + "pcb.selection", "sch.selection", "pcb.dimensions", "pcb.regions", + "pcb.images", "pcb.embedded_objects", "pcb.strings", + "pcb.length_match_groups", "sch.buses", "dmt.panels", + "pcb.differential_pairs", "pcb.poured", +}) + +#: Read-only commands this script deliberately does NOT probe, and why. +#: +#: Kept explicit so the coverage guard has something to check against. +#: Without it, a command silently dropping out of the probe list is +#: indistinguishable from one that was never meant to be in it. +NOT_PROBED: dict[str, str] = { + "editor.close_document": "needs a document uuid, and probing it would close whatever the person is looking at: a read-only classification that is still rude to exercise unasked", + "pcb.net_length": "needs a net name, and there is no net every board has", + "pcb.primitives_in_region": "needs a rectangle, and any guess is arbitrary", + "system.capabilities": "called directly before the probe loop, because its answer decides whether the rest mean anything", + "pcb.bbox": "needs primitive ids; probed with none it can only be refused", + "pcb.bboxes": "needs primitive ids, the same as pcb.bbox", + "dmt.folders": "needs the team uuid, read first from dmt.team", + "proj.get": "needs a project uuid", + "dmt.panel_info": "needs a panel uuid, read first from dmt.panels, and most installs have no panel at all", + "lib.classifications": "needs a library uuid, and there is no library every install has", + "lib.get_device": "needs a device uuid and its library", + "lib.devices_by_lcsc": "needs LCSC part numbers", + "lib.search_devices": "needs a query, and reaches the library service", + "lib.search_symbols": "needs a query, and reaches the library service", + "lib.search_footprints": "needs a query, and reaches the library service", + "lib.search_3d_models": "needs a query, and reaches the library service", + "lib.symbol_image": "needs a symbol uuid, and returns an image", + "lib.footprint_image": "needs a footprint uuid, and returns an image", + "editor.render_image": "returns an image rather than a shape to check", + "sys.document_source": "returns the whole open document; large, and read by the checkpoint tool rather than probed", +} + + +#: How many items of a list to look at when reporting its keys. The +#: whole list would be no more accurate: a list long enough to disagree +#: with itself does so within the first few dozen. +_SHAPE_SAMPLE = 24 + + +def _shape_of(items) -> str: + """The keys on a list of objects, split by whether ALL of them have it. + + This is the reason the smoke run exists. Nothing offline can + establish what EasyEDA calls the fields on a component or a pad, and + a tool written against a guessed name does not fail loudly: it reads + nothing and reports a clean empty result. + + A key on every item can be read directly. A key on only some has to + be read defensively, and reporting the union flat hides which is + which. + """ + sample = [item for item in items[:_SHAPE_SAMPLE] + if isinstance(item, dict)] + if not sample: + return "" + + everywhere = set(sample[0]) + anywhere = set() + for item in sample: + everywhere &= set(item) + anywhere |= set(item) + + parts = [f"always: {', '.join(sorted(everywhere))}"] if everywhere else [] + sometimes = anywhere - everywhere + if sometimes: + parts.append(f"sometimes: {', '.join(sorted(sometimes))}") + return "; ".join(parts) + + +def _summarise(value) -> str: + if isinstance(value, list): + shape = _shape_of(value) + return f"list[{len(value)}] {shape}" if shape else f"list[{len(value)}]" + if isinstance(value, dict): + return f"dict({', '.join(list(value)[:4])})" + text = str(value) + return text if len(text) < 48 else text[:45] + "..." + + +#: Commands whose editor-side promise NEVER SETTLES. Measured +#: reproducibly: the extension awaits an EasyEDA API call +#: that neither resolves nor rejects, so the dispatcher's own error +#: handling cannot help and the caller waits out its whole timeout. +#: +#: They are still probed, because "does it still hang?" is the question +#: a later editor version can answer differently. They just get a short +#: clock: five of them at 90 seconds is seven and a half minutes of +#: someone holding a tab open to learn nothing new. +KNOWN_HANGING = frozenset({ + "pcb.attributes", + "sch.attributes", + "sys.paths", + "pcb.strings", + "pcb.poured", + # Measured on a live schematic: no reply in 30 seconds. + # + # WHY they hang is not established. They were first written up here + # as hanging on the right document with the API present, which was + # an inference from a hand-written list of what a schematic runtime + # offers, not a measurement. The run that produced these timeouts + # DID call system.capabilities successfully and the harness recorded + # only its key names, discarding the answer, so the one artefact + # that could settle it was thrown away. + # + # These reach for sch_SelectControl and sch_ManufactureData. If + # those are absent the 0.5.9 dispatcher guard now refuses instantly + # and these entries become unnecessary; if present, the hang is + # real. Listed until a run with the capabilities reply settles it. + "sch.selection", + "sch.assembly_variants", +}) + +#: Long enough that a slow-but-working command still answers, short +#: enough that a confirmed hang costs seconds rather than minutes. +HANG_RECHECK_TIMEOUT = 10.0 + + +def local_build_id() -> "str | None": + """The build id this tree's main.js would stamp, or None.""" + import pathlib + import sys + + root = pathlib.Path(__file__).resolve().parent.parent + source = root / "extensions" / "easyeda" / "main.js" + if not source.exists(): + return None + sys.path.insert(0, str(root / "extensions" / "easyeda")) + try: + from build import build_id # type: ignore[import] + + return build_id(source.read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 + return None + + +def report_stale_build(reported: "str | None") -> bool: + """Say loudly when the editor is running a DIFFERENT build. + + build_id() has always existed and main.js has always reported it, + with a comment promising the Python side compares them. It did + not. The cost of that gap was concrete: a whole session read as + "the export fix is broken" when the editor was simply running a + build from before the fix, and the only clue was one REFUSED line + for a command the older build had never heard of. + + An extension that is installed, enabled and months old looks + identical to a current one in EasyEDA's Extensions Manager, so + this is the only cheap way to know. + """ + local = local_build_id() + if not reported or not local: + return False + if reported == local: + print(f" extension build {reported} matches this tree.\n") + return False + print() + print(" " + "!" * 66) + print(f" STALE EXTENSION: the editor is running build {reported!r},") + print(f" this tree builds {local!r}. Everything below tests the OLD") + print(" code, so a fix made since that build will read as broken.") + print(" Rebuild with `python extensions/easyeda/build.py`, then") + print(" re-import the .eext in Settings > Extensions. A re-import") + print(" of the SAME version number is a silent no-op, so bump the") + print(" version in extension.json first.") + print(" " + "!" * 66) + print() + return True + + +def filter_unmeasured(probes, known_shapes) -> list: + """The probes whose command has no recorded shape yet. + + A full run took thirteen minutes, most of it the export family + burning a 90-second timeout each. A person's connection window is + the scarce resource, not the machine's, so a run can be narrowed to + what is still unknown. + """ + known = set(known_shapes or {}) + return [p for p in probes if p[0] not in known] + + +def _populated_field_count(item) -> int: + """How many of an item's fields carry something. + + Used to pick a REPRESENTATIVE sample rather than the first one. An + optional field is null on the item that does not use it, so the + first pad being SMD is why a through-hole pad's `hole` shape went + unmeasured through two harvests. + """ + if not isinstance(item, dict): + return 0 + return sum(1 for value in item.values() + if value not in (None, "", [], {}, False)) + + +def run(bridge: EasyEdaBridge, probes, outcomes: dict, + shapes: "dict | None" = None, + samples: "dict | None" = None, + timeout: float = 90.0) -> tuple[int, int, int]: + worked = empty = failed = 0 + for command, params, key in probes: + try: + reply = bridge.send_editor_command( + command, params, timeout=timeout) + except EasyEdaNotReachableError as exc: + print(f" UNREACHABLE {command}: {exc}") + outcomes[command] = False + failed += 1 + continue + + if "error" in reply: + print(f" REFUSED {command}: {reply['error']}") + outcomes[command] = False + failed += 1 + continue + + result = reply.get("result") + if not isinstance(result, dict): + print(f" ODD SHAPE {command}: result is " + f"{type(result).__name__}, expected an object") + outcomes[command] = False + failed += 1 + continue + + if key not in result: + print(f" WRONG KEY {command}: no {key!r}; got " + f"{sorted(result)[:5]}") + outcomes[command] = False + failed += 1 + continue + + value = result[key] + # Empty is reported separately, never as a pass. On a real board + # an empty component list means the shape was misread. But an + # empty SELECTION is Tuesday, and twenty-two suspicious empties + # in one run buried the single real one among them. + if value in ([], {}, None, ""): + if command in MAY_BE_EMPTY: + print(f" empty (ok) {command}: {key} is empty, which " + f"is an ordinary state for this command. Not " + f"verified: nothing about the item shape was " + f"measurable.") + else: + print(f" EMPTY {command}: {key} is empty. On a " + f"loaded board this usually means the response " + f"shape differs from what the extension expects.") + outcomes[command] = False + empty += 1 + continue + + summary = _summarise(value) + print(f" ok {command}: {key} = {summary}") + outcomes[command] = True + if shapes is not None: + # Kept even when it is only a count. A bare "list[12]" is + # itself a finding: it means the items are plain values + # rather than objects, and no audit can be written against + # fields that are not there. + shapes[command] = summary + if samples is not None: + # One truncated example item. The shapes above answer WHICH + # keys exist; the audits blocked after the first harvest + # were blocked on what the VALUES look like (is a rule a + # number or an object, is tenting a sign or a flag), and + # only an example answers that. Machine-local, like the + # rest of the record. + example = value[0] if isinstance(value, list) else value + # A KEYED collection is a list wearing a different hat. + # sch.netlist answers {uid: {props: {...}, ...}}, and + # storing the whole dict truncated meant one component's + # parameters filled the entire budget, so whatever else an + # entry carries (pins, above all) was never seen. Sample + # ONE entry, exactly as a list is sampled. + if (isinstance(example, dict) and example + and all(isinstance(v, dict) for v in example.values())): + example = next(iter(example.values())) + try: + text = json.dumps(example, ensure_ascii=False) + except (TypeError, ValueError): + text = str(example) + # 400 characters cut the FIRST harvest's component sample + # off before otherProperty and pads, which are exactly the + # nested fields the remaining audits are blocked on, and a + # sample that stops before the interesting field measures + # nothing. Long enough to reach them, still bounded. + samples[command] = text[:2000] + # Nested objects and lists are where the shape actually + # lives: a component's `footprint` is an object and its + # `pads` a list, and knowing only that they exist is what + # let design.snapshot read a `footprintName` that was never + # there. One level down, keys only. + if isinstance(example, dict): + nested = {} + for field, inner in example.items(): + if isinstance(inner, dict): + nested[field] = sorted(inner) + elif (isinstance(inner, list) and inner + and isinstance(inner[0], dict)): + nested[field] = [f"list[{len(inner)}] of", + *sorted(inner[0])] + if nested: + samples[command + " (nested)"] = json.dumps( + nested, ensure_ascii=False)[:2000] + + # The first item is not a representative one. The first pad + # on the live board was SMD, so its `hole` was null and the + # shape a THROUGH-HOLE pad puts there stayed unmeasured; + # same for a component carrying no otherProperty and a via + # with no distinctive mask expansion. Recording the item + # with the most populated fields answers those in the SAME + # run rather than in a later one. + if isinstance(value, list) and len(value) > 1: + richest = max(value, key=_populated_field_count) + if (_populated_field_count(richest) + > _populated_field_count(example)): + try: + rich_text = json.dumps(richest, ensure_ascii=False) + except (TypeError, ValueError): + rich_text = str(richest) + samples[command + " (fullest)"] = rich_text[:2000] + worked += 1 + return worked, empty, failed + + +def main() -> int: + bridge = EasyEdaBridge() + status = bridge.start() + print(f"Listening on {status['host']}:{status['port']}") + print("Open EasyEDA Pro with the eda-agent extension installed.") + + # Sixty seconds is enough for a scripted probe and far too short for + # a person: connecting means switching to another application, + # opening a document and picking a menu item. EDA_SMOKE_WAIT + # overrides it. + try: + wait_seconds = max(5, int(os.environ.get("EDA_SMOKE_WAIT", "60"))) + except ValueError: + wait_seconds = 60 + deadline = time.time() + wait_seconds + while time.time() < deadline and not bridge.connected: + time.sleep(0.5) + + if not bridge.connected: + print(f"\nNo editor connected within {wait_seconds}s.") + print("Install the extension: build it with " + "`python extensions/easyeda/build.py`, then in EasyEDA Pro " + "use Settings > Extensions and point it at " + "extensions/easyeda/.") + bridge.stop() + return 1 + + # EasyEDA loads its API PER DOCUMENT TYPE. Its own pro-api manifest + # declares services for default / sch / symbol / pcb / panel, and on + # the start page only the reduced "default" surface exists: the + # socket works, dmt_Pcb is half there, and every pcb_* and sch_* + # class is undefined. + # + # Run the probes anyway and 64 of 65 come back "Cannot read + # properties of undefined", which reads as sixty-four bugs in this + # project. It happened, and it cost hours. So ask first. + try: + ping = (bridge.send_editor_command("system.ping", timeout=10.0) + .get("result") or {}) + kind = str(ping.get("document") or "unknown") + except Exception: # noqa: BLE001 + ping, kind = {}, "unknown" + + report_stale_build(ping.get("build")) + + if kind not in ("pcb", "schematic"): + print(f"\nConnected, but the active document is {kind!r}.") + print("EasyEDA only injects the pcb_* and sch_* API into a " + "design document, so every probe would fail with " + "'undefined' and none of those failures would be real.") + # Wait rather than exit. Re-importing an extension leaves the + # editor on its settings page, so the first connect after an + # import lands here nearly every time, and quitting costs a + # full restart plus another connect for something that is one + # click to fix. EDA_SMOKE_TAB_WAIT=0 restores the old + # exit-immediately behaviour for a scripted run. + try: + patience = float(os.environ.get("EDA_SMOKE_TAB_WAIT", "600")) + except ValueError: + patience = 600.0 + if patience > 0: + print(f"Click onto a PCB or schematic tab; waiting up to " + f"{int(patience)}s for one.", flush=True) + until = time.time() + patience + while time.time() < until and kind not in ("pcb", "schematic"): + time.sleep(3.0) + try: + ping = (bridge.send_editor_command("system.ping", + timeout=10.0) + .get("result") or {}) + kind = str(ping.get("document") or "unknown") + except Exception: # noqa: BLE001 + continue + if kind not in ("pcb", "schematic"): + print("Open a PCB or a schematic in EasyEDA Pro, then run " + "this again.") + bridge.stop() + return 1 + print(f"document is now {kind!r}, continuing", flush=True) + + # A Node harness runs this same extension against a FAKE eda whose + # board is named HARNESS-BOARD. If that harness scans ports while + # this listener is up, it connects HERE, and everything below then + # records fake data as a live measurement. That happened on + # once, and the record had to be restored by hand. + try: + boards = (bridge.send_editor_command( + "pcb.list_boards", timeout=15.0).get("result") or {}) + board_names = [str((b or {}).get("name", "")) + for b in (boards.get("boards") or []) + if isinstance(b, dict)] + except Exception: # noqa: BLE001 + board_names = [] + if any(name.startswith("HARNESS") for name in board_names): + print(f"\nThe connected client is the TEST HARNESS, not an " + f"editor: its board is named {board_names!r}. Nothing " + f"will be recorded. Stop the harness and connect the " + f"real EasyEDA.") + bridge.stop() + return 1 + + print(f"\nEditor connected, {kind} document open.\n") + + # What the editor actually injected here, before probing anything. + # A live session is rare and this is the single most informative + # call in it: one answer covers the whole surface, where the probes + # below only report the commands this project happens to have. + try: + caps = (bridge.send_editor_command( + "system.capabilities", timeout=30.0).get("result") or {}) + except Exception as exc: # noqa: BLE001 + caps = {} + print(f" capability probe failed: {exc}") + + classes = caps.get("classes") + if isinstance(classes, dict) and classes: + from eda_agent.bridge.easyeda_verified import verified_path + + target = verified_path().with_name("capabilities.json") + target.parent.mkdir(parents=True, exist_ok=True) + # The WHOLE payload, not just the class map. The extra fields + # are the diagnosis: whether a name absent from a key listing + # answers when asked for directly, and whether EasyEDA's own + # full API root is reachable from here. + target.write_text( + json.dumps({**caps, "document": kind}, indent=2, sort_keys=True), + encoding="utf-8") + methods = sum(len(v) for v in classes.values() if isinstance(v, list)) + print(f" {len(classes)} API classes present, {methods} methods. " + f"Written to {target}") + missing = [name for name in ("pcb_PrimitiveComponent", + "sch_PrimitiveComponent", + "lib_LibrariesList", "dmt_Project") + if name not in classes] + if missing: + print(f" absent in this context: {', '.join(missing)}") + + # The two questions that decide what to do about it. + enumerated = caps.get("enumerated") or [] + present = caps.get("probed_present") or [] + hidden = [n for n in present if n not in enumerated] + if hidden: + print(f" {len(hidden)} class(es) answered when asked for but " + f"did not appear in a key listing, so `eda` is lazy: " + f"{', '.join(hidden[:6])}") + else: + print(" nothing was hidden from the key listing, so the " + "surface really is reduced rather than lazy") + + root = caps.get("extapi_root") or {} + if root.get("reachable"): + print(f" EasyEDA's own API root IS reachable via " + f"{root.get('where')} with {root.get('count')} of the " + f"known classes on it") + else: + print(" EasyEDA's own API root is not reachable from the " + "extension context") + + print("Running READ-ONLY probes.\n") + outcomes: dict[str, bool] = {} + shapes: dict[str, str] = {} + samples: dict[str, str] = {} + # Schematic reads FAIL inside the editor while the PCB canvas is + # active, and the other way round: measured live, sch.components + # answers "failed to get all components" and three sch probes each + # burn the full 90s timeout. Probing them from the wrong tab is + # four and a half minutes of noise that reads as breakage, so the + # wrong-tab family is set aside by NAME instead. + other = "sch." if kind == "pcb" else "pcb." + runnable = [p for p in PROBES if not p[0].startswith(other)] + deferred = [p[0] for p in PROBES if p[0].startswith(other)] + if deferred: + print(f" {len(deferred)} {other}* probes need the " + f"{'schematic' if other == 'sch.' else 'pcb'} tab active " + f"and are set aside; run again with that tab focused to " + f"cover them.\n") + + # EDA_SMOKE_NEW narrows the run to what nothing has measured yet. + only_new = os.environ.get("EDA_SMOKE_NEW", "").strip().lower() in ( + "1", "true", "yes") + slow = SLOW_PROBES + if only_new: + from eda_agent.bridge.easyeda_verified import load_verified + + known = (load_verified() or {}).get("shapes") or {} + before = len(runnable) + len(slow) + runnable = filter_unmeasured(runnable, known) + slow = filter_unmeasured(slow, known) + print(f" EDA_SMOKE_NEW: {before - len(runnable) - len(slow)} " + f"already-measured commands skipped; probing " + f"{len(runnable) + len(slow)}.\n") + + hangs = [p for p in runnable if p[0] in KNOWN_HANGING] + runnable = [p for p in runnable if p[0] not in KNOWN_HANGING] + + worked, empty, failed = run(bridge, runnable, outcomes, shapes, + samples) + + if hangs: + print(f"\n{len(hangs)} commands measured to hang, re-checked on a " + f"{HANG_RECHECK_TIMEOUT:.0f}s clock:\n") + wh, eh, fh = run(bridge, hangs, outcomes, shapes, samples, + timeout=HANG_RECHECK_TIMEOUT) + worked, empty, failed = worked + wh, empty + eh, failed + fh + if wh: + print(f" {wh} of them ANSWERED this time: the editor " + f"changed, so update KNOWN_HANGING.") + + if slow: + print("\nSlower checks (the editor's own DRC and ERC):\n") + # MEASURED, not guessed at: an export that works answers in + # seconds (the whole family came back EMPTY promptly on + # measured), while the ones that fail never settle at all, so + # the editor's promise hangs and the full timeout is dead time. + # Eight of them at 90s is twelve minutes of a person holding a tab + # open. 30s is far above any real export here and cuts that to + # four. A genuine export that needs longer shows up as a + # timeout, which is a finding rather than a silent loss. + w2, e2, f2 = run(bridge, slow, outcomes, shapes, samples, + timeout=30.0) + worked, empty, failed = worked + w2, empty + e2, failed + f2 + + total = worked + empty + failed + print(f"\n{worked}/{total} answered with data, {empty} empty, " + f"{failed} failed.") + + # Record what was measured, per command. This is the only writer: + # verified_live reads it rather than carrying an opinion, so a + # command is verified exactly when a real editor answered it with + # usable data, and never because someone edited a constant. + from eda_agent.bridge.easyeda_verified import record_verified + + editor = None + try: + editor = str(bridge.send_editor_command( + "system.ping", timeout=10.0).get("result", {}).get("api")) + except Exception: # noqa: BLE001 - the record is optional + editor = None + + path = record_verified( + outcomes, editor, + time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime()), + shapes=shapes, samples=samples) + print(f"\nRecorded {sum(outcomes.values())} verified command(s) and " + f"{len(shapes)} response shape(s) to {path}") + print("The shapes are the field names a tool has to be written " + "against. Nothing offline can establish them, so a live run " + "is the only place they exist.") + + if empty or failed: + print("\nEmpty and failed results are the interesting ones: they " + "are where the assumed response shape and the editor's " + "actual one disagree. Report them rather than retrying.") + else: + print("\nEvery probe returned data.") + + bridge.stop() + return 0 if not failed else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/easyeda_tool_sweep.py b/scripts/easyeda_tool_sweep.py new file mode 100644 index 0000000..95e0a13 --- /dev/null +++ b/scripts/easyeda_tool_sweep.py @@ -0,0 +1,691 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Drive every read-only EasyEDA TOOL against a live editor. + +`easyeda_smoke.py` probes bridge COMMANDS. This probes the MCP tools, +and the difference is not cosmetic: a command can round-trip perfectly +while the tool wrapping it reads a field the editor never sends. Three +tools shipped that way and were caught only when a live board +contradicted them, because every test in the suite fed them the shape +they expected. + +Two phases, one connection, because a live session is scarce. + +Phase 1 runs the tools classified ``readonly`` whose arguments all have +defaults. Nothing it calls mutates the design. + +Phase 2 dumps the KEY SET of a representative object from the reads +that block the remaining audits. Those audits are not blocked on +effort; they are blocked on nobody knowing whether the editor reports +the field they would need. Measuring beats another guess. + +Refusals come before results, and in a deliberate order. A build +mismatch is checked FIRST: EasyEDA installs by version, so re-importing +a package whose version matches the installed one is a silent no-op, +and every other diagnosis is meaningless against unknown code. An +earlier revision of this script printed the mismatch and carried on to +blame the document type, naming the wrong one of two candidate faults. +""" +from __future__ import annotations + +import asyncio +import inspect +import json +import os +import pathlib +import sys +import time +import traceback + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + +from eda_agent.tools import metadata as M +from eda_agent.tools import register_backend +from eda_agent.tools.registry import ToolRegistry + +#: Commands measured to hang. A tool fanning out to one hangs with it, +#: so they are named rather than discovered the expensive way. +HANGING_COMMANDS = frozenset({ + "pcb.attributes", "sch.attributes", "sys.paths", "pcb.strings", + "pcb.poured", + # Measured on a live schematic: no reply in 30 seconds. Why they + # hang is not established, and it may be the editor's behaviour + # rather than this extension's. + "sch.selection", "sch.assembly_variants", +}) + +#: How long to wait on a command that has hung before. The extension +#: gives up at 15s and says so, so this only has to outlast that: the +#: aim is to collect ITS message, which names the command and says the +#: call was accepted rather than refused, instead of timing out here +#: and reporting the less specific "no reply". +#: +#: Four of these now try a SECOND read path before giving up +#: (getAllPrimitiveId then get per item, where getAll stalls), and the +#: reply carries a `via` field saying which one answered. That field is +#: the point of probing them: "ids" means three board audits and one +#: library check stop being blocked. +HANG_PROBE_TIMEOUT = 25.0 + +#: The four hanging reads that now have a fallback. Worth calling out +#: separately in the output, because their `via` is a finding rather +#: than a detail. +FALLBACK_READS = frozenset({ + "sch.attributes", "pcb.attributes", "pcb.strings", "pcb.poured", +}) + +#: Reads whose object shape decides whether a blocked audit is +#: implementable. The second element is the field the payload sits under. +SHAPE_TARGETS = ( + ("pcb.images", "images"), # non-embedded images + ("pcb.pads", "pads"), # removed pad shapes + ("pcb.vias", "vias"), # tented via ratio + ("pcb.components", "components"), # lock state, rotation + ("pcb.regions", "regions"), + ("pcb.fills", "fills"), + ("pcb.embedded_objects", "embedded_objects"), + ("pcb.net_classes", "net_classes"), # routing DRC data + ("pcb.rule_configurations", "rule_configurations"), + ("sch.components", "components"), # ports / labels / power + ("sch.pins", "pins"), + ("sch.wires", "wires"), + ("sch.buses", "buses"), + ("sch.netlist", "netlist"), + # PCB reads carry a numeric layer, and which integer is top copper + # decides whether a board ships with reversed silkscreen and which + # side a net is routed on. The rows are a list, each carrying id, + # name and type; type is the field the classifiers read. + ("pcb.layers", "layers"), +) + +#: Reads that need an argument, so they cannot go in SHAPE_TARGETS. +#: The value says where to get it. +#: +#: pcb.bboxes gives easyeda_plan_placement its component sizes, and +#: without it placement falls back to pad extents, which understate a +#: footprint and pack parts too tightly. +#: +#: The payload sits under `boxes`, not `bboxes`, and each entry is +#: {primitive_id, bbox: {minX, minY, maxX, maxY}}. Both the key and the +#: corner names differ from the x1/y1 form used elsewhere, which is why +#: the field is named here rather than inferred from the command. +ARGUMENT_SHAPE_TARGETS = ( + ("pcb.bboxes", "boxes", "pcb.components", "components", "primitiveId"), +) + +#: A design document only. EasyEDA injects the pcb_*/sch_* API per +#: document type, so on the start page every probe fails with +#: "undefined" and not one of those failures is real. +DESIGN_DOCUMENTS = frozenset({"pcb", "schematic"}) + + +def selectable_tools(registry) -> list: + """The tools this sweep is allowed to call, and no others. + + Two filters, each load-bearing. ``readonly`` excludes the 109 tools + classified ``silent``, which mutate without a dialog: pointing + those at a real board is not a smoke test, it is an edit. And a + tool with a required argument cannot be called blind, so passing + nothing would only measure how it reports a missing argument. + """ + out = [] + for name in sorted(registry.names): + if not name.startswith("easyeda_"): + continue + if M.interaction_of(name) != M.READONLY: + continue + fn = registry.get(name).fn + params = inspect.signature(fn).parameters.values() + if any(p.default is inspect.Parameter.empty for p in params): + continue + out.append((name, fn)) + return out + + +def refusal(ping: dict, expected_build: str): + """Why this session must not be recorded, or None to proceed. + + Ordered by how badly a wrong answer misleads. An unexpected build + invalidates everything downstream, so it is judged before the + document type rather than after. + """ + build = ping.get("build") + if build != expected_build: + return (4, f"the editor is running build {build!r} but this tree " + f"builds {expected_build!r}. EasyEDA installs BY " + f"VERSION, so re-importing at the same version is a " + f"silent no-op: bump extension.json, rebuild, and " + f"import that.") + + # A Node harness runs this same extension against a fake editor whose + # board is HARNESS-BOARD. If it scans ports while this listener is + # up it connects HERE, and fake data gets recorded as a live + # measurement. That happened, and the record had to be repaired. + if str(ping.get("board") or "").upper().startswith("HARNESS"): + return (2, "the test harness connected, not a real editor.") + + kind = str(ping.get("document") or "unknown") + if kind not in DESIGN_DOCUMENTS: + return (3, f"the active document is {kind!r}. EasyEDA only " + f"injects the pcb_*/sch_* API into a design document, " + f"so every probe would fail with 'undefined' and none " + f"of those failures would be real.") + return None + + +#: Tools whose ANSWER is the point, not just its shape. +#: +#: The sweep records key names for everything, which is right for a +#: board read where the rows are the user's design and not this +#: project's business. It is exactly wrong for these: system.capabilities +#: exists to say which API classes are present in this runtime, and a +#: live run recorded that its reply had a `probed_present` key while +#: throwing away the list. Every later question about why a command +#: hung then had to be answered from a guessed list of what was +#: available, which is how a measurement session ends up producing +#: inferences. +_ANSWER_MATTERS = frozenset({ + "easyeda_get_capabilities", + "easyeda_get_environment", + "easyeda_get_project_info", + "easyeda_get_measured_shapes", +}) + + +def harvest_outcomes(shapes: dict) -> tuple: + """Turn harvest results into (command -> usable, command -> fields). + + Separated out so the rule can be tested rather than merely written + down. The rule is that ONLY harvested commands reach the shared + verification record: they were issued raw and their replies read + raw, which is a measurement. A tool verdict is not, because a tool + can fan out to several commands or refuse on its own arguments + before sending anything, so filing one as a command fact is an + inference dressed as evidence. + + An EMPTY container is False, matching the record's own rule: on a + loaded board an empty result usually means the reply shape was + misread, and that must never be filed as a success. + """ + outcomes: dict = {} + field_names: dict = {} + for command, got in (shapes or {}).items(): + if not isinstance(got, dict) or "skipped" in got: + continue + if "." not in command: + # A tool name, not a command. Refused rather than skipped: + # something is feeding the wrong collection in. + raise ValueError( + f"{command!r} is not a command name; only harvested " + f"commands may reach the verification record") + usable = bool(got.get("count")) + outcomes[command] = usable + keys = got.get("sample_keys") + if usable and keys: + field_names[command] = ", ".join(keys) + return outcomes, field_names + + +def _try_open_a_design_document(bridge) -> str: + """List what exists and open one, reporting what happened. + + Returns a sentence rather than raising: this runs while the sweep is + deciding whether it can proceed at all, and a failure here is a + measurement (editor.open_document does not work) rather than a + reason to abandon the session. + """ + for command, field, kind in (("sch.list_schematics", "schematics", + "schematic"), + ("pcb.list_boards", "boards", "PCB")): + try: + reply = bridge.send_editor_command(command, timeout=15.0) + except Exception as exc: # noqa: BLE001 + return f"{command} failed: {exc}" + if "error" in reply: + return f"{command} refused: {reply['error']}" + + items = (reply.get("result") or {}).get(field) or [] + if not items: + continue + + first = items[0] + uuid = "" + if isinstance(first, dict): + uuid = str(first.get("uuid") or first.get("id") or "") + elif isinstance(first, str): + uuid = first + if not uuid: + return (f"{command} listed {len(items)} {kind}(s) but none " + f"carried a uuid: {str(first)[:90]}") + + try: + opened = bridge.send_editor_command( + "editor.open_document", {"uuid": uuid}, timeout=20.0) + except Exception as exc: # noqa: BLE001 + return f"editor.open_document({uuid[:8]}...) failed: {exc}" + if "error" in opened: + return f"editor.open_document refused: {opened['error']}" + return f"opened a {kind} ({uuid[:8]}...) via editor.open_document" + + return "nothing to open: no schematics and no boards were listed" + + +def classify(reply, elapsed: float, name: str = "") -> dict: + """One tool's outcome, in the shape the report is built from.""" + if isinstance(reply, dict): + failed = reply.get("ok") is False or "error" in reply + out = {"verdict": "refused" if failed else "ok", + "seconds": elapsed, + "keys": sorted(reply)[:14], + "reason": reply.get("reason") or reply.get("error")} + if name in _ANSWER_MATTERS: + out["reply"] = reply + return out + return {"verdict": "ok", "seconds": elapsed, + "type": type(reply).__name__} + + +def _shape(payload) -> dict: + if isinstance(payload, dict): + first = next(iter(payload.values()), None) + container, count = "dict", len(payload) + elif isinstance(payload, list): + first = payload[0] if payload else None + container, count = "list", len(payload) + else: + return {"container": type(payload).__name__, "value": payload} + return {"container": container, "count": count, + "sample_keys": sorted(first) if isinstance(first, dict) else None, + "sample": first} + + +def main() -> int: + from eda_agent.bridge import easyeda_bridge as EB + from eda_agent.bridge.easyeda_bridge import EasyEdaBridge + from easyeda_smoke import local_build_id + + out_path = pathlib.Path( + os.environ.get("EDA_SWEEP_OUT", "easyeda_tool_sweep.json")) + + bridge = EasyEdaBridge() + status = bridge.start() + EB._BRIDGE = bridge # the tools resolve through the singleton + print(f"Listening on {status['host']}:{status['port']}", flush=True) + print("Connect EasyEDA Pro with a PCB or schematic tab open.", + flush=True) + + wait = int(os.environ.get("EDA_SWEEP_WAIT", "900")) + deadline = time.time() + wait + while time.time() < deadline and not bridge.connected: + time.sleep(0.5) + if not bridge.connected: + print(f"\nNo editor connected within {wait}s.", flush=True) + bridge.stop() + return 1 + + # Give a second tab a moment to dial in. The bridge keeps one + # connection per editor runtime now, and a session with both a PCB + # and a schematic connected is the one that proves the routing: + # pcb_* does not exist in the schematic runtime, so a misrouted + # command fails in a way no single-tab run can show. + grace = float(os.environ.get("EDA_SWEEP_SECOND_TAB_GRACE", "20")) + settle = time.time() + grace + while time.time() < settle and len(getattr(bridge, "_conns", {})) < 2: + time.sleep(0.5) + + ping = (bridge.send_editor_command("system.ping", timeout=15.0) + .get("result") or {}) + print(f"\nCONNECTED document={ping.get('document')!r} " + f"build={ping.get('build')!r} api={ping.get('api')!r}", flush=True) + + editors = getattr(bridge, "_conns", {}) + print(f"editor runtimes connected: {len(editors)}", flush=True) + if len(editors) > 1 and hasattr(bridge, "_learn_contexts"): + bridge._learn_contexts() + contexts = sorted(str(i.get("context")) + for i in bridge._conns.values()) + print(f" contexts: {contexts}", flush=True) + elif len(editors) == 1: + print(" (only one tab; open a PCB AND a schematic to exercise " + "namespace routing)", flush=True) + + verdict = refusal(ping, local_build_id()) + + # A wrong tab is worth WAITING through rather than exiting on. + # + # Re-importing an extension leaves EasyEDA on its settings page, so + # the first connect after an import reports the document as + # "unknown" almost every time. Exiting there costs a full restart + # of the listener and another connect, for something the person can + # fix in one click. The build mismatch is different and still exits + # at once: that needs a rebuild and a re-import, so waiting would + # only stall. + if verdict is not None and verdict[0] == 3: + print(f"\n{verdict[1]}", flush=True) + + # Optionally open one instead of waiting for a human. + # + # The extension has the whole discovery-and-open loop: + # sch.list_schematics / pcb.list_boards name what exists and + # editor.open_document opens one by uuid. The listing halves are + # confirmed working live; editor.open_document has NEVER been + # measured, so trying it here both removes the manual click and + # settles whether it works. + # + # OPT-IN, because opening a document changes what is in front of + # the person watching. That is not a design edit, but it is + # still their screen, and a harness should not rearrange it + # uninvited. + if os.environ.get("EDA_SWEEP_TRY_OPEN") == "1": + print("EDA_SWEEP_TRY_OPEN=1: asking the editor to open a " + "design document itself.", flush=True) + opened = _try_open_a_design_document(bridge) + print(f" {opened}", flush=True) + try: + ping = (bridge.send_editor_command("system.ping", + timeout=10.0) + .get("result") or {}) + verdict = refusal(ping, local_build_id()) + if verdict is None: + print(f" it worked: document is now " + f"{ping.get('document')!r}", flush=True) + except Exception as exc: # noqa: BLE001 + print(f" ping after open failed: {exc}", flush=True) + + if verdict is not None and verdict[0] == 3: + print("Waiting for a design tab: click onto a PCB or schematic " + "and this will carry on by itself.", flush=True) + patience = float(os.environ.get("EDA_SWEEP_TAB_WAIT", "600")) + until = time.time() + patience + while time.time() < until: + time.sleep(3.0) + try: + ping = (bridge.send_editor_command("system.ping", + timeout=10.0) + .get("result") or {}) + except Exception: # noqa: BLE001 + continue + verdict = refusal(ping, local_build_id()) + if verdict is None: + print(f"\ndocument is now {ping.get('document')!r}, " + f"continuing", flush=True) + break + + if verdict is not None: + code, why = verdict + print(f"\nREFUSING: {why}", flush=True) + bridge.stop() + return code + + registry = ToolRegistry() + register_backend(registry, "easyeda", "full") + targets = selectable_tools(registry) + print(f"\nsweeping {len(targets)} read-only tools\n", flush=True) + + results = {} + for i, (name, fn) in enumerate(targets, 1): + started = time.time() + try: + reply = asyncio.run(asyncio.wait_for(fn(), timeout=25)) + results[name] = classify( + reply, round(time.time() - started, 2), name) + except asyncio.TimeoutError: + results[name] = {"verdict": "timeout", + "seconds": round(time.time() - started, 2)} + except Exception as exc: # noqa: BLE001 + results[name] = {"verdict": "raised", + "seconds": round(time.time() - started, 2), + "reason": f"{type(exc).__name__}: {exc}", + "trace": traceback.format_exc()[-600:]} + print(f"[{i:3}/{len(targets)}] {results[name]['verdict']:8} {name}", + flush=True) + + # The review is the point, not a line in a table of 90 verdicts. + # + # easyeda_review_board is selected like any other read-only tool, so + # a real design review of the open board happens on every run and + # was being recorded as "ok" next to eighty-nine others. What it + # FOUND is the thing worth reading, and it is also the first live + # evidence that the audits work against a real board rather than + # against the measured shapes they were written from. + review = results.get("easyeda_review_board") + if review and review.get("verdict") == "ok": + try: + reply = asyncio.run(asyncio.wait_for( + registry.get("easyeda_review_board").fn(), timeout=90)) + except Exception as exc: # noqa: BLE001 + reply = {"ok": False, "reason": str(exc)} + print("\n--- design review of the open board ---", flush=True) + if reply.get("ok"): + print(f" {reply.get('audits_run')} of " + f"{reply.get('audits_total')} audits produced a count; " + f"{reply.get('total_violations')} violations", + flush=True) + for finding in reply.get("findings") or []: + print(f" {finding['violation_count']:5} " + f"{finding['audit']}", flush=True) + for entry in (reply.get("refused") or [])[:8]: + print(f" refused: {entry.get('audit')} " + f"({str(entry.get('reason'))[:60]})", flush=True) + else: + print(f" refused: {reply.get('reason')}", flush=True) + # NOT into `results`. That map is one entry per TOOL CALL, each + # carrying a verdict, and the tally at the end reads that key + # off every entry. Filing a raw reply here crashed the run + # AFTER the harvest had been printed, which is the worst place + # for it: the data was on screen and the summary never came. + review_detail = reply + + # What the API can actually DO here, for the work that is blocked on + # exactly that question. + # + # system.capabilities enumerates the METHODS on every class the + # runtime exposes, which is the answer to "can EasyEDA create an + # assembly variant / annotate a schematic / remove a document" - + # questions currently parked because they were assumed to need the + # installed api-types.d.ts. They do not. The reply already carries + # it and the harness was throwing it away. + _BLOCKED_ON = { + "sch_ManufactureData": "assembly variants: read works, is there a " + "create/set?", + "dmt_EditorControl": "opening and switching documents", + "sch_Document": "document-level operations (remove, save)", + "pcb_Document": "document-level operations", + "pcb_Layer": "the layer vocabulary two library audits need", + "sch_PrimitiveComponent": "annotation, replace-component", + } + try: + caps = (registry.get("easyeda_get_capabilities").fn) + cap_reply = asyncio.run(asyncio.wait_for(caps(), timeout=30)) + classes = (cap_reply or {}).get("classes") or {} + print("\n--- API surface for the blocked questions ---", flush=True) + for name, why in sorted(_BLOCKED_ON.items()): + methods = classes.get(name) + if methods is None: + print(f" {name:26} ABSENT in this runtime ({why})", + flush=True) + else: + print(f" {name:26} {len(methods)} methods ({why})", + flush=True) + print(f" {', '.join(methods)}", flush=True) + + # Then EVERY class, because the six above are a guess about + # where a capability lives. `annotate` might sit on a document + # class, or on one nothing here has ever called. Pre-filtering + # what to look at is how a measurement session ends up needing + # a second measurement session. + others = sorted(set(classes) - set(_BLOCKED_ON)) + if others: + print(f"\n every other class ({len(others)}):", flush=True) + for name in others: + print(f" {name:28} {len(classes[name])}", flush=True) + + # And name the methods that would answer the open questions, + # wherever they turn out to live. + wanted = ("annotat", "variant", "replace", "remove", "delete", + "rename", "parameter", "layer", "open", "close") + hits = [] + for name, methods in sorted(classes.items()): + for method in methods: + low = method.lower() + if any(w in low for w in wanted): + hits.append(f"{name}.{method}") + if hits: + print(f"\n methods matching the open questions " + f"({len(hits)}):", flush=True) + for hit in hits: + print(f" {hit}", flush=True) + except Exception as exc: # noqa: BLE001 + print(f"\ncould not read the API surface: {exc}", flush=True) + + print("\n--- shape harvest ---", flush=True) + shapes = {} + for command, field in SHAPE_TARGETS: + # The known hangs are PROBED, not skipped. + # + # Skipping them was right while a hang was unbounded: one such + # read cost the rest of the run. Since the extension started + # answering its own timeout the cost is a bounded failure, and + # skipping now only guarantees the shape stays unknown. Two of + # these decide whether a blocked audit is implementable at all, + # so the answer is worth the wait. + # + # A little past the extension's own ceiling, so its timeout + # message arrives rather than this side giving up first and + # reporting a less specific failure. + timeout = 20.0 + if command in HANGING_COMMANDS: + timeout = HANG_PROBE_TIMEOUT + try: + reply = bridge.send_editor_command(command, timeout=timeout) + if command in FALLBACK_READS: + route = (reply.get("result") or {}).get("via") + if route: + print(f" {command}: answered via {route}" + + (" <- the fallback works, four items " + "unblock" if route == "ids" else ""), + flush=True) + # The editor's own error, kept rather than flattened. + # + # This used to read (result or {}).get(field) and report the + # shape of whatever came back. When the command ERRORED the + # reply carries `error` and no `result`, so that produced + # the single word "NoneType" for every failure and threw + # away the message. A live run then reported seven reads as + # NoneType, which says the field is missing; the editor had + # actually said "Cannot read properties of null", which + # says something entirely different and is the only clue to + # what went wrong. + if "error" in reply: + shapes[command] = {"editor_error": str(reply["error"])} + else: + shapes[command] = _shape( + (reply.get("result") or {}).get(field)) + except Exception as exc: # noqa: BLE001 + shapes[command] = {"error": f"{type(exc).__name__}: {exc}"} + got = shapes[command] + print(f" {command:26} " + f"{got.get('count', got.get('editor_error', got.get('error', got.get('container'))))}", + flush=True) + + # Reads that need an argument, fed from a read that does not. + # + # These cannot sit in SHAPE_TARGETS because probing them with + # nothing only measures how they report a missing argument, which + # is not the question. The question is what a REAL answer looks + # like, and three shipped features guess at it. + for command, field, source, source_field, id_field in \ + ARGUMENT_SHAPE_TARGETS: + try: + first = bridge.send_editor_command(source, timeout=20.0) + rows = ((first.get("result") or {}).get(source_field)) or [] + ids = [str(r.get(id_field)) for r in rows + if isinstance(r, dict) and r.get(id_field)] + if not ids: + shapes[command] = { + "skipped": f"{source} reported no {id_field} to ask about"} + print(f" {command:26} no ids from {source}", flush=True) + continue + # A handful is enough to see the shape, and asking about + # every component on a large board is a slow way to learn + # the same thing. + reply = bridge.send_editor_command( + command, {"primitive_ids": ids[:5]}, timeout=30.0) + if "error" in reply: + shapes[command] = {"editor_error": str(reply["error"])} + else: + payload = (reply.get("result") or {}).get(field) + shapes[command] = _shape(payload) + # The shape summary says list-or-dict; for this one the + # FIELD NAMES inside decide whether placement can read + # it, so a sample is kept. + sample = None + if isinstance(payload, dict): + for value in payload.values(): + sample = value + break + elif isinstance(payload, list) and payload: + sample = payload[0] + if isinstance(sample, dict): + shapes[command]["sample_keys"] = sorted(sample) + except Exception as exc: # noqa: BLE001 + shapes[command] = {"error": f"{type(exc).__name__}: {exc}"} + got = shapes[command] + print(f" {command:26} " + f"{got.get('sample_keys', got.get('editor_error', got.get('error', got.get('skipped'))))}", + flush=True) + + # Fold the harvest into the shared verification record. + # + # Only the HARVEST, never the tool results. The record maps a + # COMMAND to whether it returned usable data; the sweep's first + # phase drives tools, and a tool can fan out to several commands or + # refuse on its own arguments before sending anything. Filing a tool + # verdict as a command fact would be an inference wearing the + # clothes of a measurement, which is the thing this record exists to + # keep out. The harvest issues raw commands and reads raw replies, + # so those are measurements and belong here. + # + # An EMPTY container counts as False, matching the record's own + # rule: on a loaded board an empty result usually means the reply + # shape was misread, and that must never be filed as a success. + try: + from eda_agent.bridge.easyeda_verified import record_verified + + outcomes, field_names = harvest_outcomes(shapes) + if outcomes: + record_verified( + outcomes, str(ping.get("api") or "") or None, + time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime()), + shapes=field_names) + print(f"\nrecorded {sum(outcomes.values())} of " + f"{len(outcomes)} harvested commands to the " + f"verification record", flush=True) + except Exception as exc: # noqa: BLE001 + # The record is a by-product. Losing it must not lose the run. + print(f"\ncould not update the verification record: {exc}", + flush=True) + + out_path.write_text( + json.dumps({"document": ping.get("document"), + "build": ping.get("build"), + "results": results, "shapes": shapes, + "review_detail": review_detail}, + indent=2, default=str), + encoding="utf-8") + + from collections import Counter + # .get, not [], so an entry that somehow lacks a verdict is + # reported as such rather than ending the run. A summary is the + # last thing printed and the first thing read. + tally = Counter( + (r.get("verdict", "no-verdict") if isinstance(r, dict) + else "no-verdict") + for r in results.values()) + print(f"\n=== {dict(tally)} ===\nwritten to {out_path}", flush=True) + bridge.stop() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/README.md b/skills/README.md index 4d7fc04..c6a10eb 100644 --- a/skills/README.md +++ b/skills/README.md @@ -20,4 +20,6 @@ Then `/autodesign` is available in that project. **Other clients**: the same protocol is available without any skill file: call the `design_autonomy_guide` tool, or invoke the `autonomous_design` MCP -prompt. See [../docs/AUTONOMOUS_DESIGN.md](../docs/AUTONOMOUS_DESIGN.md). +prompt. Both need the design harness, which the Altium and EasyEDA backends +register and the KiCad backend does not. +See [../docs/AUTONOMOUS_DESIGN.md](../docs/AUTONOMOUS_DESIGN.md). diff --git a/skills/autodesign/SKILL.md b/skills/autodesign/SKILL.md index 12e5a92..007afc7 100644 --- a/skills/autodesign/SKILL.md +++ b/skills/autodesign/SKILL.md @@ -1,6 +1,6 @@ --- name: autodesign -description: Drive an autonomous spec-to-board PCB design with the eda-agent MCP server (Altium). Apply when the user asks to design a board/schematic from a requirement, wants an end-to-end autonomous design run, or mentions the design harness, design_next_action, design sessions, or spec-to-board. Requires the eda-agent MCP server connected to a running Altium Designer. +description: Drive an autonomous spec-to-board PCB design with the eda-agent MCP server (Altium Designer or EasyEDA Pro). Apply when the user asks to design a board/schematic from a requirement, wants an end-to-end autonomous design run, or mentions the design harness, design_next_action, design sessions, or spec-to-board. Requires the eda-agent MCP server connected to a running Altium Designer or EasyEDA Pro; the KiCad backend does not register the design harness. --- # Autonomous PCB design (eda-agent) @@ -13,8 +13,11 @@ pipeline, because the integrity lives server-side. ## Before you start -- Confirm the eda-agent MCP server is connected and Altium is running - (`app_get_status`). If not, tell the user how to start it; don't guess. +- Confirm the editor is actually answering, with `app_ping` on Altium or + `easyeda_ping` on EasyEDA. Ping, not `app_get_status`: status reports + that the process exists and that something once called attach, and + neither of those proves the bridge replies. If it does not answer, tell + the user how to start it; don't guess. - Read `design_get_discipline` once: the hard rules and the DesignPlan schema. Or call `design_autonomy_guide` for the full protocol + the 13 stages with their tools and exit gates. @@ -23,8 +26,9 @@ pipeline, because the integrity lives server-side. 1. `design_session_start(requirement)` opens the durable journal. Keep the returned `session_id`; every later call takes it. -2. If a project is open or will be modified, `app_checkpoint("before - autonomous run")` so the whole run is revertible in one step. +2. If a project is open or will be modified, checkpoint first so the whole + run is revertible in one step: `app_checkpoint("before autonomous run")` + on Altium, `easyeda_checkpoint` on EasyEDA. 3. Loop: `design_next_action(session_id)` and act on `status`: - **proceed / retry**: do the stage using its `suggested_tools` until the `exit_gate` is met, then diff --git a/src/eda_agent/bridge/easyeda_bridge.py b/src/eda_agent/bridge/easyeda_bridge.py new file mode 100644 index 0000000..c2c6dc0 --- /dev/null +++ b/src/eda_agent/bridge/easyeda_bridge.py @@ -0,0 +1,811 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Talk to EasyEDA Pro through its extension API. + +THE CONNECTION RUNS THE OTHER WAY from the Altium bridge. Altium polls a +directory for request files, so this process writes and waits. EasyEDA +Pro's extension API dials out instead (``SYS_WebSocket.register``), so +this process LISTENS and the editor connects to it. Nothing here can +start EasyEDA or make it connect; until the extension does, every call +reports the source as unreachable and says how to start it. + +Requests are correlated by id, the same way the Altium bridge matches +``response_.json`` to its request, so a slow reply cannot be +mistaken for the answer to a later question. + +WHAT IS VERIFIED. The framing is RFC 6455 and is tested against the +specification's own worked example. The transport shape comes from +EasyEDA's published extension API. What has NOT been exercised is a live +editor: no part of this has round-tripped against EasyEDA Pro, which is +why ``verified_live`` is False and why the health report says so rather +than implying a working link. + +LOOPBACK ONLY. This listens for one local editor. It is not hardened for +a hostile network and binds to 127.0.0.1 unless told otherwise. +""" + +from __future__ import annotations + +import json +import os +import socket +import threading +import time +import uuid +from typing import Any, Optional + +from eda_agent.bridge.websocket import ( + OPCODE_CLOSE, + OPCODE_PING, + OPCODE_PONG, + OPCODE_TEXT, + FrameError, + build_frame, + handshake_response, + parse_frame, +) + +__all__ = [ + "EasyEdaBridge", + "EasyEdaNotReachableError", + "get_easyeda_bridge", +] + +#: Loopback by default. An editor extension runs on the same machine, and +#: binding wider would expose a command channel that executes edits. +_DEFAULT_HOST = "127.0.0.1" +_DEFAULT_PORT = 8787 + +#: The range EasyEDA's own bridge server scans. Matching it means the +#: extension finds this server without a port being configured, and a +#: port already in use stops being a dead end. +PORT_RANGE_START = 49620 +PORT_RANGE_END = 49629 + +#: How long a single command may take. Generous because a board-wide +#: query in a browser runtime is not fast, bounded because a hung editor +#: must not wedge the server. +_DEFAULT_TIMEOUT = 30.0 + + +#: Returned by GET /health so a scanning client can tell this server +#: apart from whatever else happens to be on the port. +SERVICE_ID = "eda-agent-bridge" + + +class _HealthProbe(Exception): + """Not a WebSocket peer. Answered and closed, not an error worth logging.""" + + +class EasyEdaNotReachableError(RuntimeError): + """No editor is connected, so the request was never delivered. + + Deliberately distinct from a command that ran and failed. "Nothing + is listening" and "EasyEDA refused that edit" call for different + responses, and collapsing them would send the user to debug the + wrong end. + """ + + +def _host() -> str: + return os.environ.get("EDA_AGENT_EASYEDA_HOST", "").strip() or _DEFAULT_HOST + + +def _port() -> int: + raw = os.environ.get("EDA_AGENT_EASYEDA_PORT", "").strip() + if not raw: + return _DEFAULT_PORT + try: + return int(raw) + except ValueError: + return _DEFAULT_PORT + + +class EasyEdaBridge: + """A WebSocket server that one EasyEDA extension connects to.""" + + @property + def verified_live(self) -> bool: + """Has ANY command round-tripped against a live EasyEDA Pro? + + Read from the record the smoke script writes, never hardcoded. + A constant here could only ever be an opinion, and this project + has already been burned by published metadata that was derived + rather than measured. + + False on a fresh checkout, and that is the correct answer. + """ + from eda_agent.bridge.easyeda_verified import load_verified + + return any(load_verified()["commands"].values()) + + def verified_live_for(self, command: str) -> bool: + """Has THIS command round-tripped against a live editor? + + The global flag above answers "has anything ever worked", which + after the first successful session is true forever and says + nothing about the tool at hand. Twenty commands verified and + forty-five not is a distinction worth keeping: a tool built on + pcb.components has been seen working, one built on + pcb.attributes has been seen hanging, and reporting the same + flag for both launders the second with the first's evidence. + """ + from eda_agent.bridge.easyeda_verified import is_verified + + return is_verified(command) + + def __init__(self) -> None: + self._server: Optional[socket.socket] = None + self._client: Optional[socket.socket] = None + self._buffer = bytearray() + self._lock = threading.Lock() + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self._connected_at: Optional[float] = None + self._bound_port: Optional[int] = None + # Every connected editor runtime, keyed by its socket. + # + # EasyEDA injects its API PER DOCUMENT TYPE: a PCB tab and a + # schematic tab are separate runtimes, and pcb_* simply does + # not exist in the schematic one. With a single connection the + # second tab to connect evicted the first, so the sch-to-PCB + # flow, which is the whole point of the tool, could never run + # in one session. Altium reaches both from one connection, and + # this is what closes that gap. + # + # The value is {buffer, context, at}. Each connection needs its + # OWN frame buffer: they interleave on the wire, and a shared + # buffer would hand one editor's half-frame to the other. + self._conns: "dict[socket.socket, dict[str, Any]]" = {} + + #: Extension build id -> when this process first saw it. The id + #: is a content hash and carries no ordering, so first-seen is + #: what makes one build "newer" than another. + self._build_first_seen: "dict[str, float]" = {} + #: Builds retired because a newer one appeared. Reported rather + #: than discarded: "your editor was running three builds" is the + #: explanation for a fix that looked like it did not work. + self._retired_builds: "set[str]" = set() + + # ---- lifecycle --------------------------------------------------- + + def start(self) -> dict[str, Any]: + """Listen for the editor. Returns where it is listening.""" + if self._server is not None: + return self.status() + + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + # NOT SO_REUSEADDR on Windows. There it means something close to + # the opposite of the Unix behaviour: it permits binding a port + # another socket is ALREADY listening on, so two bridges both + # "succeed" on the same port and then compete for connections. + # Port scanning depends on a taken port failing to bind, so with + # SO_REUSEADDR the scan can never move past the first candidate. + # + # SO_EXCLUSIVEADDRUSE is the Windows option that makes bind() + # refuse when the port is in use. Elsewhere, SO_REUSEADDR keeps + # its usual meaning of reclaiming a TIME_WAIT port, which is + # what a restart needs. + exclusive = getattr(socket, "SO_EXCLUSIVEADDRUSE", None) + if exclusive is not None: + server.setsockopt(socket.SOL_SOCKET, exclusive, 1) + else: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + # Try the range EasyEDA's own bridge uses, in order, unless a + # port was named explicitly. The extension scans the same range + # and identifies the server by its /health reply, so neither + # side needs a port agreed by hand and a taken port is no longer + # a dead end. + candidates = ([_port()] if os.environ.get("EDA_AGENT_EASYEDA_PORT", + "").strip() + else list(range(PORT_RANGE_START, PORT_RANGE_END + 1)) + + [_DEFAULT_PORT]) + bound = None + for candidate in candidates: + try: + server.bind((_host(), candidate)) + except OSError: + continue + bound = candidate + break + + if bound is None: + server.close() + raise EasyEdaNotReachableError( + f"no free port for the EasyEDA bridge. Tried " + f"{candidates[0]}-{candidates[-1]} on {_host()}. Set " + f"EDA_AGENT_EASYEDA_PORT to a free one.") + self._bound_port = bound + server.listen(1) + server.settimeout(0.5) + self._server = server + self._stop.clear() + self._thread = threading.Thread(target=self._accept_loop, daemon=True) + self._thread.start() + return self.status() + + def stop(self) -> None: + self._stop.set() + with self._lock: + for sock in (self._client, self._server): + if sock is not None: + try: + sock.close() + except OSError: + pass + self._client = None + self._server = None + self._connected_at = None + + def _accept_loop(self) -> None: + while not self._stop.is_set() and self._server is not None: + try: + conn, _ = self._server.accept() + except (socket.timeout, OSError): + continue + try: + self._handshake(conn) + except _HealthProbe: + # Discovery, not a peer. Close and keep listening. + try: + conn.close() + except OSError: + pass + continue + except (FrameError, OSError): + # A stray connection is not an error worth propagating + # from a background thread; the editor will retry. + try: + conn.close() + except OSError: + pass + continue + with self._lock: + # Keep the earlier connection. It used to be closed + # here, so opening a PCB tab silently killed the + # schematic one and every later sch_* call failed with + # "not connected" while a schematic sat open on screen. + self._conns[conn] = {"buffer": bytearray(), + "context": None, + "context_at": 0.0, + "at": time.time()} + self._client = conn + self._buffer = self._conns[conn]["buffer"] + self._connected_at = time.time() + self._evict_dead_locked() + + def _handshake(self, conn: socket.socket) -> None: + conn.settimeout(5.0) + raw = b"" + while b"\r\n\r\n" not in raw: + chunk = conn.recv(4096) + if not chunk: + raise FrameError("connection closed during handshake") + raw += chunk + if len(raw) > 16384: + raise FrameError("handshake headers are implausibly large") + + head = raw.split(b"\r\n\r\n", 1)[0].decode("latin-1") + lines = head.split("\r\n") + request_line = lines[0] if lines else "" + headers: dict[str, str] = {} + for line in lines[1:]: + if ":" in line: + name, _, value = line.partition(":") + headers[name.strip()] = value.strip() + + # A plain GET /health, no Upgrade. This is how a client finds the + # right server: EasyEDA's own bridge scans a port range and reads + # a service identifier back, rather than having a port configured + # by hand. Answering it means the extension can DISCOVER this + # server and, just as importantly, retry until it appears. + # + # Without that, register() fails silently whenever nothing is + # listening at the moment of the call and never tries again, + # which is exactly how a correct extension and a correct server + # can sit side by side and never meet. + # The Upgrade header's VALUE is "websocket"; "upgrade" is what + # the Connection header says. Testing the wrong one classified + # every real handshake as a health probe. + lowered = {k.lower(): v.lower() for k, v in headers.items()} + if lowered.get("upgrade", "") != "websocket": + if request_line.startswith("GET /health"): + body = json.dumps({ + "service": SERVICE_ID, + "status": "ok", + "editor_connected": self.connected, + }).encode("utf-8") + conn.sendall( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: application/json\r\n" + b"Access-Control-Allow-Origin: *\r\n" + + f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + + body) + raise _HealthProbe() + conn.sendall(b"HTTP/1.1 404 Not Found\r\n" + b"Content-Length: 0\r\n\r\n") + raise _HealthProbe() + + conn.sendall(handshake_response(headers)) + + # ---- state ------------------------------------------------------- + + @property + def connected(self) -> bool: + with self._lock: + return self._client is not None + + def status(self) -> dict[str, Any]: + with self._lock: + return { + "listening": self._server is not None, + "host": _host(), + # The port actually bound, which is not the + # requested one when the range was scanned. + "port": self._bound_port or _port(), + "editor_connected": self._client is not None, + # One editor runtime per open document type. Reporting + # only a boolean hid the case this bridge now handles: + # with a PCB and a schematic both connected, "connected: + # true" said nothing about whether the schematic was + # among them, and a sch_* failure looked like a bug in + # the tool rather than a tab nobody had opened. + "editors_connected": len(self._conns), + # "unidentified" was read as a failed probe, and with a + # single connection nothing is ever probed: routing only + # learns contexts when there is a choice to make. Saying + # "not probed" keeps a design decision from looking like + # a broken editor, and it stopped a reconnection test + # from drawing a conclusion the data did not support. + "editor_contexts": sorted( + str(info.get("context") + or ("not probed (single connection)" + if len(self._conns) == 1 else "unidentified")) + for info in self._conns.values()), + # WHICH BUILD IS ANSWERING. Re-importing the extension + # leaves the previous instance running with its socket + # open, so an editor can hold several builds at once and + # a command lands on whichever is picked. Reporting the + # set is what turns "the fix did not work" into "you are + # talking to the old one". + "editor_builds": sorted( + {str(info["build"]) for info in self._conns.values() + if info.get("build")}), + "builds_retired": sorted(self._retired_builds), + "connected_seconds": ( + round(time.time() - self._connected_at, 1) + if self._connected_at else None), + # Stated, not implied. The transport can be up and the + # command vocabulary still unproven against a live app. + "verified_live": self.verified_live, + } + + # ---- commands ---------------------------------------------------- + + def send_editor_command(self, command: str, params: Optional[dict] = None, + timeout: float = _DEFAULT_TIMEOUT) -> dict[str, Any]: + """Run one command in the editor and return its reply.""" + # Route to the runtime that owns this namespace. Only when more + # than one editor is connected: with a single connection there + # is nothing to choose, and identifying it would spend a round + # trip to reach the same socket. + namespace = command.split(".", 1)[0] + if len(self._conns) > 1 and namespace in self._NAMESPACE_CONTEXT: + self._learn_contexts() + with self._lock: + chosen = self._select_locked(command) + if chosen is not None: + self._activate_locked(chosen) + + with self._lock: + client = self._client + if client is None: + raise EasyEdaNotReachableError( + # The port actually BOUND, never the requested one. With + # scanning they differ routinely, and this message is + # what someone reads when nothing connects: naming the + # wrong port sends them to check a socket that was never + # opened. + f"no EasyEDA editor is connected. This server listens on " + f"{_host()}:{self._bound_port or _port()} and the editor " + f"dials out to it, so " + f"start the eda-agent extension in EasyEDA Pro " + f"(Settings > Extensions) and point it here. If the " + f"extension reports that external interaction for " + f"extensions and standalone scripts is not permitted, " + f"enable that permission in EasyEDA first: until it is " + f"on, the editor never attempts a socket and nothing on " + f"this side can see the difference from an editor that " + f"is simply closed. Nothing was " + f"sent, so this is not evidence the command would fail.") + + request_id = uuid.uuid4().hex + message = json.dumps({ + "id": request_id, "command": command, "params": params or {}, + }).encode("utf-8") + + try: + client.sendall(build_frame(message, opcode=OPCODE_TEXT)) + except OSError as exc: + self._drop_client() + raise EasyEdaNotReachableError( + f"the editor connection dropped while sending: {exc}" + ) from exc + + return self._await_reply(request_id, timeout, client) + + def _await_reply(self, request_id: str, timeout: float, + client: "Optional[socket.socket]" = None + ) -> dict[str, Any]: + """Wait for one reply on the connection the request went out on. + + The socket is passed in rather than re-read from self. Routing + can rebind the active connection between the send and the + reply, and re-reading would then wait on the OTHER editor's + socket and consume its frames: a schematic command could eat a + PCB command's answer. With one connection the two were always + the same object, which is why nothing here needed to say so + before. + """ + deadline = time.time() + timeout + if client is None: + with self._lock: + client = self._client + while time.time() < deadline: + if client is None or client.fileno() < 0: + raise EasyEdaNotReachableError( + "the editor disconnected before replying") + + frame = self._next_frame(client, deadline) + if frame is None: + continue + opcode, payload = frame + + if opcode == OPCODE_CLOSE: + self._drop_client() + raise EasyEdaNotReachableError("the editor closed the link") + if opcode == OPCODE_PING: + try: + client.sendall(build_frame(payload, opcode=OPCODE_PONG)) + except OSError: + self._drop_client() + continue + if opcode != OPCODE_TEXT: + continue + + try: + reply = json.loads(payload.decode("utf-8", "replace")) + except ValueError: + continue + # Ignore replies to earlier requests rather than returning + # one as the answer to this question. + if isinstance(reply, dict) and reply.get("id") == request_id: + return reply + + raise EasyEdaNotReachableError( + f"no reply within {timeout}s. The editor is connected but did " + f"not answer, which usually means the extension raised.") + + def _next_frame(self, client: socket.socket, + deadline: float) -> Optional[tuple[int, bytes]]: + # This connection's OWN buffer, looked up by socket rather than + # taken from self._buffer. Two editors interleave on the wire, + # and a buffer that belongs to whichever connection was + # activated last would hand one editor's half-frame to the + # other: the frame parser would then read a length prefix from + # the middle of somebody else's message. + buffer = self._buffer_for(client) + parsed = parse_frame(bytes(buffer)) + if parsed is not None: + opcode, payload, consumed = parsed + del buffer[:consumed] + return opcode, payload + + client.settimeout(max(0.05, min(1.0, deadline - time.time()))) + try: + chunk = client.recv(65536) + except socket.timeout: + return None + except OSError as exc: + self._drop_client() + raise EasyEdaNotReachableError( + f"the editor connection dropped: {exc}") from exc + if not chunk: + self._drop_client() + raise EasyEdaNotReachableError("the editor closed the link") + buffer.extend(chunk) + return None + + def _buffer_for(self, client: socket.socket) -> bytearray: + """The frame buffer belonging to one connection. + + Falls back to the shared buffer for a socket the pool has never + seen, which keeps the single-connection path working even if a + caller reaches _next_frame with a socket acquired some other + way. + """ + with self._lock: + info = self._conns.get(client) + if info is not None: + return info["buffer"] + return self._buffer + + def _note_build_locked(self, sock, build: str) -> None: + """Record which extension build a socket is running, and retire + the superseded ones. Caller holds the lock. + + A SUPERSEDED INSTANCE KEEPS ANSWERING. EasyEDA does not tear the + old extension down on re-import: the previous instance keeps + running and keeps its socket open, so one editor process held + SEVEN connections across three builds at once. They cannot be + told apart by document context, because every one of them says + "schematic". + + Preferring the newest CONNECTION does not fix it either. Every + instance reattaches on its own timer, so a stale one becomes the + most recent connection a few seconds later, and which build + answers a given command is then a coin toss. That is how a fix + verified against one build was measured as absent minutes later, + and it is worse than confusing: a write can be executed by the + build whose bug it was fixing. + + So the build itself decides. The newest build observed wins, and + connections on any other are dropped once a newer one is known. + A build is "newer" by when it was FIRST SEEN here, since the id + is a content hash and carries no ordering of its own. + """ + info = self._conns.get(sock) + if info is None: + return + info["build"] = build + self._build_first_seen.setdefault(build, time.time()) + + newest = max(self._build_first_seen.items(), key=lambda kv: kv[1])[0] + if build == newest and len(self._build_first_seen) > 1: + # This socket is on the current build, so anything on an + # older one is a leftover instance. Never drop the last + # connection: an old build answering is better than none. + for other, other_info in list(self._conns.items()): + if other is sock or len(self._conns) <= 1: + continue + if other_info.get("build") and other_info["build"] != newest: + self._retired_builds.add(other_info["build"]) + self._conns.pop(other, None) + try: + other.close() + except OSError: + pass + elif build != newest: + # Learned about a stale instance. Leave it in place if it is + # all there is; the block above retires it as soon as the + # current build is seen again. + info["superseded_by"] = newest + + def _evict_dead_locked(self) -> None: + """Forget sockets that are closed. Caller holds the lock. + + A tab the user closed leaves a dead socket behind, and routing + to it would refuse a command the OTHER editor could have run. + """ + for sock in [s for s in self._conns if s.fileno() < 0]: + self._conns.pop(sock, None) + + #: Which document runtime a command namespace needs. Namespaces not + #: listed here exist in every runtime (lib, proj, sys, system, dmt, + #: editor), so they run on whichever editor is connected and are + #: never worth a second round trip to place. + _NAMESPACE_CONTEXT = {"pcb": "pcb", "sch": "schematic"} + + #: Commands whose namespace does not say which runtime they need. + #: + #: The namespaces above were once thought to be the whole story, and + #: export and design were listed as running anywhere. They do not: + #: every command here reaches a pcb_* or sch_* class, so routing one + #: to the other runtime sends it somewhere it cannot work while a + #: connection that could have run it sits idle. + #: + #: Derived from the class family each handler actually touches, and + #: kept in step with the same table in the extension. + _COMMAND_CONTEXT = { + "design.snapshot": "pcb", + "design.run_drc": "pcb", + "design.run_erc": "schematic", + "export.bom": "pcb", + "export.dxf": "pcb", + "export.model_3d": "pcb", + "export.gerber": "pcb", + "export.ipc2581": "pcb", + "export.ipcd356": "pcb", + "export.netlist": "pcb", + "export.altium": "pcb", + "export.pdf": "pcb", + "export.pick_and_place": "pcb", + "export.test_points": "pcb", + "export.flying_probe": "pcb", + "export.dsn": "pcb", + "export.pads": "pcb", + "export.pcb_info": "pcb", + "export.schematic_document": "schematic", + "export.schematic_netlist": "schematic", + "export.sch_bom": "schematic", + "export.simulation_netlist": "schematic", + } + + def _select_locked(self, command: str) -> Optional[socket.socket]: + """Pick the connection that can actually run this command. + + Falls back to the most recent connection rather than refusing: + a wrong guess produces the editor's own error, while refusing + would fail a command that would have worked on a single + connection. This must never be worse than one connection was. + """ + namespace = command.split(".", 1)[0] + wanted = (self._COMMAND_CONTEXT.get(command) + or self._NAMESPACE_CONTEXT.get(namespace)) + if wanted is not None: + for sock, info in sorted(self._conns.items(), + key=lambda kv: kv[1]["at"], + reverse=True): + if info.get("context") == wanted: + return sock + if self._client in self._conns: + return self._client + newest = sorted(self._conns.items(), key=lambda kv: kv[1]["at"], + reverse=True) + return newest[0][0] if newest else None + + def _activate_locked(self, sock: socket.socket) -> None: + self._client = sock + info = self._conns.get(sock) + if info is not None: + self._buffer = info["buffer"] + self._connected_at = info["at"] + + #: How long a learned document context is trusted, in seconds. + #: + #: Caching it forever was the first design, justified by "EasyEDA + #: gives each document runtime its own extension host, so the answer + #: cannot change without the socket being replaced". That is an + #: assumption, not a measurement, and the extension reads the + #: context with getCurrentPcbInfo / getCurrentSchematicInfo, whose + #: names say they report the ACTIVE tab rather than a fixed identity + #: of the connection. If one socket does serve whatever tab is in + #: front, a cached context goes stale the moment somebody clicks + #: another tab, and routing then sends pcb.* to a connection now + #: showing a schematic: a wrong answer that looks like a right one. + #: + #: Re-asking costs one ping. Being wrong costs a command executed + #: against the wrong document, so it is re-asked until somebody has + #: measured which way EasyEDA actually behaves. + _CONTEXT_TTL_SECONDS = 30.0 + + def _learn_contexts(self) -> None: + """Ask each connection which document it is, if we do not know. + + Lazily, and again once the last answer is older than the TTL. + """ + with self._lock: + self._evict_dead_locked() + now = time.time() + unknown = [ + s for s, i in self._conns.items() + if i.get("context") is None + or now - i.get("context_at", 0.0) > self._CONTEXT_TTL_SECONDS + ] + for sock in unknown: + with self._lock: + if sock not in self._conns: + continue + self._activate_locked(sock) + try: + reply = self.send_editor_command("system.ping", timeout=8.0) + except Exception: # noqa: BLE001 + # A connection that cannot answer a ping is not usable + # for routing, and nothing else will ever notice. + # `fileno() < 0` only becomes true once this side calls + # close(), so a socket whose peer vanished stays in the + # table forever: it was reported as an unidentified + # editor, cost the full ping timeout on every context + # refresh, and could be picked as the fallback target. + # + # A MISSED PING IS NOT PROOF OF DEATH, and this has to + # fail toward keeping the connection. The bridge + # serialises calls, so a context probe competing with a + # long read times out while the editor is perfectly + # healthy. Evicting on that drops a working editor and + # the user sees tools refuse for no reason. + # + # So: three strikes, and NEVER the last connection. An + # editor that cannot be pinged is still better than no + # editor at all, and if it really is gone the next + # accept replaces it anyway. Measured going the other + # way first, where two strikes took a live schematic + # out of the table. + with self._lock: + info = self._conns.get(sock) + if info is not None: + info["ping_misses"] = info.get("ping_misses", 0) + 1 + if info["ping_misses"] >= 3 and len(self._conns) > 1: + self._conns.pop(sock, None) + try: + sock.close() + except OSError: + pass + continue + result = reply.get("result") or {} + document = str(result.get("document") or "") + build = str(result.get("build") or "") + with self._lock: + if sock in self._conns: + self._conns[sock]["context"] = document or "unknown" + self._conns[sock]["context_at"] = time.time() + self._conns[sock]["ping_misses"] = 0 + # Which BUILD answered. Re-importing the extension + # leaves the previous instance running with its + # socket open, so the editor can hold several at + # once, and they are indistinguishable by document + # context because they all report the same one. + if build: + self._note_build_locked(sock, build) + + def _drop_client(self) -> None: + with self._lock: + if self._client is not None: + try: + self._client.close() + except OSError: + pass + self._conns.pop(self._client, None) + self._client = None + self._connected_at = None + self._buffer = bytearray() + # Fall back to another live editor rather than reporting + # nothing connected while one is still open. + self._evict_dead_locked() + remaining = sorted(self._conns.items(), + key=lambda kv: kv[1]["at"], reverse=True) + if remaining: + self._activate_locked(remaining[0][0]) + + def ping(self) -> dict[str, Any]: + """Liveness, reported honestly when nothing is connected.""" + if not self.connected: + raise EasyEdaNotReachableError( + f"no EasyEDA editor connected on {_host()}:{_port()}") + reply = self.send_editor_command("system.ping", timeout=5.0) + return {"success": True, "editor": reply.get("result", {}), + "verified_live": self.verified_live} + + +_BRIDGE: Optional[EasyEdaBridge] = None + + +def get_easyeda_bridge() -> EasyEdaBridge: + """The process-wide bridge, LISTENING by the time it is returned. + + Starting it here is the whole point. EasyEDA dials out, so nothing + can connect until this process is listening, and the accessor is the + only place that knows a bridge is about to be used. Creating one + without starting it produced a backend that could never work: the + server never listened, the extension had nothing to discover, and + every tool reported "no editor connected" forever. Both halves can + be perfect and never meet. + + Every test starts the bridge explicitly, which is exactly why none + of them could see this. + + A failure to bind is not raised. The tools report unreachability as + data, and a server that cannot listen is a reason the editor is + unreachable rather than a reason to fail an unrelated call. + """ + global _BRIDGE + if _BRIDGE is None: + _BRIDGE = EasyEdaBridge() + if not _BRIDGE.status()["listening"]: + try: + _BRIDGE.start() + except EasyEdaNotReachableError: + pass + return _BRIDGE diff --git a/src/eda_agent/bridge/easyeda_expected.py b/src/eda_agent/bridge/easyeda_expected.py new file mode 100644 index 0000000..fe08ca5 --- /dev/null +++ b/src/eda_agent/bridge/easyeda_expected.py @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Compare the running extension against the one this tree builds. + +The editor runs an installed copy of the extension and nothing keeps +the two in step. EasyEDA installs by version, so importing a package +whose version is already installed has no effect, and the editor +continues to run the older code while every call still succeeds. + +The build id is a hash of main.js with its own BUILD_ID line +neutralised. It is the value build.py stamps into the package, so this +compares code rather than a version string that may not have been +bumped. +""" + +from __future__ import annotations + +import json +import pathlib +from typing import Any, Optional + +#: The extension source, present when the server runs from a checkout. +#: An installed wheel has no extensions directory; the check then +#: reports nothing rather than guessing. +_ROOT = pathlib.Path(__file__).resolve().parents[3] +_EXTENSION_DIR = _ROOT / "extensions" / "easyeda" +_MAIN_JS = _EXTENSION_DIR / "main.js" +_MANIFEST = _EXTENSION_DIR / "extension.json" + +#: EasyEDA blocks extension network access until this permission is +#: granted. Until then the editor never opens a socket, which on this +#: side is indistinguishable from the editor being closed, so it is +#: worth naming wherever a connection problem is reported. +PERMISSION_HINT = ( + "If the extension reports that external interaction for extensions " + "and standalone scripts is not permitted, enable that permission in " + "EasyEDA first. Until it is on, the editor never attempts a " + "connection." +) + +_cache: dict[str, Any] = {} + + +def _source_stamp() -> float: + """Modification time of the extension source, or 0 when absent. + + The cache is keyed on this. Caching the build id outright means a + rebuild during a running session is never noticed, so the check + keeps comparing against the value read at startup and reports a + match while the tree has moved on. + """ + try: + return _MAIN_JS.stat().st_mtime + except OSError: + return 0.0 + + +def expected_build() -> Optional[str]: + """The build id this tree's main.js would stamp, or None.""" + stamp = _source_stamp() + if _cache.get("stamp") == stamp and "build" in _cache: + return _cache["build"] + _cache.clear() + _cache["stamp"] = stamp + value = None + if _MAIN_JS.exists(): + import sys + + sys.path.insert(0, str(_EXTENSION_DIR)) + try: + from build import build_id # type: ignore[import] + + value = build_id(_MAIN_JS.read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 + value = None + finally: + if sys.path and sys.path[0] == str(_EXTENSION_DIR): + sys.path.pop(0) + _cache["build"] = value + return value + + +def expected_version() -> Optional[str]: + """The version in extension.json, for a message a reader can act on.""" + expected_build() # refreshes the cache when the source moved + if "version" in _cache: + return _cache["version"] + value = None + if _MANIFEST.exists(): + try: + value = str(json.loads( + _MANIFEST.read_text(encoding="utf-8")).get("version") or "") + except Exception: # noqa: BLE001 + value = None + _cache["version"] = value or None + return _cache["version"] + + +def package_path() -> Optional[str]: + """The .eext to import, so the message names a file to open.""" + candidate = _EXTENSION_DIR / "eda-agent-bridge.eext" + return str(candidate) if candidate.exists() else None + + +def check(reported_build: Optional[str]) -> dict[str, Any]: + """Compare the reported build against this tree's. + + Returns an empty dict when the two agree or when either side cannot + say, so a caller can merge the result unconditionally and add + nothing in the normal case. + """ + wanted = expected_build() + if not wanted or not reported_build or reported_build == wanted: + return {} + version = expected_version() + where = package_path() or "extensions/easyeda/eda-agent-bridge.eext" + return { + "extension_outdated": True, + "extension_build_running": reported_build, + "extension_build_expected": wanted, + "extension_version_expected": version, + "extension_action": ( + f"The editor is running extension build {reported_build}; " + f"this server expects {wanted}" + + (f", version {version}" if version else "") + + f". Import {where} in EasyEDA Pro under Settings, " + "Extensions. Importing a package whose version is already " + "installed has no effect, so check the version changed. " + + PERMISSION_HINT + ), + } diff --git a/src/eda_agent/bridge/easyeda_verified.py b/src/eda_agent/bridge/easyeda_verified.py new file mode 100644 index 0000000..7c53f78 --- /dev/null +++ b/src/eda_agent/bridge/easyeda_verified.py @@ -0,0 +1,181 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Which EasyEDA commands have actually round-tripped against a live editor. + +``verified_live`` started as a constant False, which was honest but +useless: it could only ever say "nothing here is proven", and flipping +it by hand would turn a measurement into an opinion. This project has +been bitten by exactly that before, when published tool maturity was +DERIVED rather than measured and advertised 121 tools as simulator +tested that the simulator rejects. + +So verification is recorded per command, by the smoke script, from a +real editor. Nothing else writes this file. A command absent from it is +unverified, and absence is the default rather than something to argue +about. + +The record is deliberately NOT committed. It describes one machine's +one session against one version of EasyEDA, and shipping it would +present someone else's measurement as this user's. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any, Optional + +__all__ = ["load_verified", "record_verified", "sample_of", "shape_of", + "verified_path"] + +#: path -> (mtime_ns, parsed record). See load_verified. +_CACHE: dict = {} + + +def verified_path() -> Path: + """Where the record lives. Overridable so tests never touch the real one.""" + configured = os.environ.get("EDA_AGENT_EASYEDA_VERIFIED", "").strip() + if configured: + return Path(configured) + return (Path(__file__).resolve().parents[3] + / "extensions" / "easyeda" / "verified.json") + + +def load_verified() -> dict[str, Any]: + """The recorded verification, or an empty record. + + An unreadable or corrupt file reads as "nothing verified" rather + than raising. The consequence of getting this wrong is a claim that + something works, so the failure direction has to be toward the + modest answer. + """ + path = verified_path() + try: + # Cached on the file's mtime: _call consults this on every + # command, and rereading a JSON file per tool call is waste the + # moment two calls happen in one session. A new smoke run + # changes the mtime and drops the cache. + stat = path.stat() + cached = _CACHE.get(str(path)) + if cached is not None and cached[0] == stat.st_mtime_ns: + return cached[1] + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {"commands": {}, "editor": None, "recorded_at": None, + "shapes": {}, "samples": {}} + if not isinstance(data, dict) or not isinstance( + data.get("commands"), dict): + return {"commands": {}, "editor": None, "recorded_at": None} + # Records written before shapes were captured have no such key, and + # a missing shape has to read as "not measured" rather than raise. + if not isinstance(data.get("shapes"), dict): + data["shapes"] = {} + if not isinstance(data.get("samples"), dict): + data["samples"] = {} + _CACHE[str(path)] = (stat.st_mtime_ns, data) + return data + + +def record_verified(commands: dict[str, bool], editor: Optional[str], + recorded_at: str, + shapes: "Optional[dict[str, str]]" = None, + samples: "Optional[dict[str, str]]" = None) -> Path: + """Write the record. Only a live harness may call this. + + Two do: ``easyeda_smoke.py`` and the shape-harvest half of + ``easyeda_tool_sweep.py``. Both issue raw commands to a real editor + and read the raw replies. Nothing that INFERS a command's outcome + from something else belongs here, which is why the sweep records + its harvest and not its tool verdicts: a tool can fan out to several + commands, or refuse on its own arguments before sending anything. + + ``commands`` maps a command name to whether it returned usable data. + A command that answered but came back EMPTY is False: on a loaded + board an empty result means the response shape was misread, which is + the failure this whole exercise looks for and must never be filed as + a success. + + ``shapes`` maps a command to the FIELD NAMES its result carried. + Nothing offline can establish those: the published API reference + lists methods, not the shape of what they return, so a tool written + against a guessed key reads nothing and reports a clean empty + result. A live session is the only place the answer exists, and + printing it to a terminal loses it as soon as the buffer scrolls. + """ + path = verified_path() + path.parent.mkdir(parents=True, exist_ok=True) + + # MERGE, never replace. A run can only probe the document context + # that is open: with a PCB tab the sch.* probes are set aside by + # name, and with a schematic tab the pcb.* ones are. Writing the + # payload flat meant the second connection ERASED the first, which + # is exactly what happened: a schematic run wiped 20 PCB shapes, 20 + # samples and every pcb.* verified flag, so tools measured in an + # earlier session went back to reporting unverified. + # + # A command this run did NOT probe keeps whatever was established + # before. A command it DID probe takes the new answer, including a + # newly failing one, because the editor really can change. + previous = load_verified() + merged_commands = dict(previous.get("commands") or {}) + merged_commands.update({k: bool(v) for k, v in commands.items()}) + merged_shapes = dict(previous.get("shapes") or {}) + merged_shapes.update({k: str(v) for k, v in (shapes or {}).items()}) + merged_samples = dict(previous.get("samples") or {}) + merged_samples.update({k: str(v) for k, v in (samples or {}).items()}) + + payload = { + "commands": dict(sorted(merged_commands.items())), + "shapes": dict(sorted(merged_shapes.items())), + # One truncated example item per command. The shapes give the + # KEY names; the next tranche of audits was blocked one level + # deeper, on value FORMATS (is a rule value a number or an + # object, is tenting a flag or a sign), and only an example + # answers that. + "samples": dict(sorted(merged_samples.items())), + "editor": editor, + "recorded_at": recorded_at, + "note": ("Written by scripts/easyeda_smoke.py against a live " + "EasyEDA Pro. Not committed: it describes one machine's " + "session, not a property of this project."), + } + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return path + + +def is_verified(command: str) -> bool: + """Has this exact command returned usable data from a real editor?""" + return bool(load_verified()["commands"].get(command)) + + +def verified_summary() -> dict[str, Any]: + """Counts for a status report, without asserting anything untrue.""" + record = load_verified() + commands = record["commands"] + return { + "verified_commands": sorted(k for k, v in commands.items() if v), + "verified_count": sum(1 for v in commands.values() if v), + "recorded_at": record.get("recorded_at"), + "editor": record.get("editor"), + } + + +def shape_of(command: str) -> str: + """The field names this command's result carried, when measured. + + Empty when no live session has recorded it. That is the honest + answer: an audit written against a field nobody has seen is a guess, + and this is how to tell the two apart. + """ + return str(load_verified().get("shapes", {}).get(command, "")) + + +def sample_of(command: str) -> str: + """A truncated example of this command's reply item, when measured. + + Empty when no live session has recorded one, which is the honest + answer for the same reason shape_of gives it. + """ + return str(load_verified().get("samples", {}).get(command, "")) + diff --git a/src/eda_agent/bridge/websocket.py b/src/eda_agent/bridge/websocket.py new file mode 100644 index 0000000..bf80f65 --- /dev/null +++ b/src/eda_agent/bridge/websocket.py @@ -0,0 +1,247 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""A minimal RFC 6455 server, written here rather than depended on. + +EasyEDA Pro's extension API talks to the outside world by REGISTERING a +WebSocket connection to a server (``SYS_WebSocket.register``), which +means the editor dials out and this process listens. That is the reverse +of the Altium bridge, where Altium polls a directory for request files. + +Only the server half is implemented, and only the parts that a single +trusted local client needs: the opening handshake, text and binary data +frames, close, ping and pong. Extension negotiation, ``permessage-deflate`` +and fragmentation across many frames are deliberately absent, and a frame +this cannot honour is refused rather than half-handled. + +Written in-house for the same reason the s-expression reader and the +EasyEDA part converter were: the framing rules are then verified here +instead of trusted, and the server keeps its stdlib-only footprint. The +protocol is a published standard, so nothing is guessed. + +SCOPE, stated plainly: this listens on the loopback interface for one +local editor. It is not hardened for a hostile network, does not do TLS, +and must not be exposed beyond localhost. +""" + +from __future__ import annotations + +import base64 +import hashlib +import os +import struct +from typing import Optional + +__all__ = [ + "FrameError", + "OPCODE_BINARY", + "OPCODE_CLOSE", + "OPCODE_PING", + "OPCODE_PONG", + "OPCODE_TEXT", + "accept_key", + "build_frame", + "handshake_response", + "parse_frame", +] + +#: Fixed by RFC 6455 section 1.3. Concatenated with the client key before +#: hashing, which is what proves the peer spoke WebSocket rather than +#: having stumbled onto the port. +_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +OPCODE_CONTINUATION = 0x0 +OPCODE_TEXT = 0x1 +OPCODE_BINARY = 0x2 +OPCODE_CLOSE = 0x8 +OPCODE_PING = 0x9 +OPCODE_PONG = 0xA + +#: Refuse a frame claiming more than this rather than allocating for it. +#: A local editor sending a board snapshot is comfortably inside 64 MB, +#: and an unbounded length field is how a stray connection turns into an +#: out-of-memory crash. +MAX_PAYLOAD = 64 * 1024 * 1024 + + +class FrameError(ValueError): + """A frame that cannot be honoured, rather than one half-parsed.""" + + +def accept_key(client_key: str) -> str: + """The Sec-WebSocket-Accept value for a client's Sec-WebSocket-Key. + + RFC 6455 section 4.2.2: append the GUID, take SHA-1, base64 it. The + client checks this, so an incorrect implementation fails at connect + time rather than silently later. + """ + digest = hashlib.sha1((client_key.strip() + _GUID).encode("ascii")) + return base64.b64encode(digest.digest()).decode("ascii") + + +def handshake_response(headers: dict[str, str]) -> bytes: + """The 101 response for a client's request headers. + + Header names are matched case-insensitively because HTTP says they + are case-insensitive and clients genuinely differ. + """ + lowered = {k.lower(): v for k, v in headers.items()} + key = lowered.get("sec-websocket-key") + if not key: + raise FrameError("handshake has no Sec-WebSocket-Key") + if lowered.get("upgrade", "").lower() != "websocket": + raise FrameError("handshake is not an Upgrade: websocket request") + version = lowered.get("sec-websocket-version", "").strip() + if version and version != "13": + # 13 is the only version RFC 6455 defines. Saying so beats + # accepting a version whose framing may differ. + raise FrameError(f"unsupported WebSocket version {version!r}") + + return ( + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {accept_key(key)}\r\n" + "\r\n" + ).encode("ascii") + + +def build_frame(payload: bytes, opcode: int = OPCODE_TEXT, + mask: bool = False) -> bytes: + """One unfragmented frame. + + A server MUST NOT mask (RFC 6455 section 5.1), so ``mask`` defaults + off and exists only so tests can build client frames, which MUST be + masked. Having both directions in one function is what lets the + round-trip test drive the real parser rather than a stub. + """ + if opcode not in (OPCODE_CONTINUATION, OPCODE_TEXT, OPCODE_BINARY, + OPCODE_CLOSE, OPCODE_PING, OPCODE_PONG): + raise FrameError(f"unknown opcode {opcode:#x}") + if len(payload) > MAX_PAYLOAD: + raise FrameError( + f"payload of {len(payload)} bytes exceeds the {MAX_PAYLOAD} " + f"byte limit") + + header = bytearray() + header.append(0x80 | opcode) # FIN set, no RSV bits + + length = len(payload) + flag = 0x80 if mask else 0x00 + if length < 126: + header.append(flag | length) + elif length < (1 << 16): + header.append(flag | 126) + header.extend(struct.pack("!H", length)) + else: + header.append(flag | 127) + header.extend(struct.pack("!Q", length)) + + if not mask: + return bytes(header) + payload + + key = os.urandom(4) + masked = bytes(b ^ key[i % 4] for i, b in enumerate(payload)) + return bytes(header) + key + masked + + +def parse_frame(data: bytes) -> Optional[tuple[int, bytes, int]]: + """Parse one MESSAGE from the front of ``data``. + + Returns ``(opcode, payload, bytes_consumed)``, or None when ``data`` + holds less than a whole message. Returning None rather than raising + is what lets a caller accumulate from a socket without having to + know the length in advance. + + A fragmented message (FIN clear, then continuation frames) is + reassembled here, and the reported opcode is the FIRST fragment's. + The real EasyEDA Pro editor fragments large replies; Node's client + never does, so no harness caught it, and the first live board with + real component data killed the session mid-probe. + + One honest limit remains: a CONTROL frame interleaved between the + fragments of one message (legal per the RFC) is refused rather than + reordered, because this parser reports a consumed PREFIX and cannot + hand frames back out of order. The editor has not been seen doing + it; if it ever does, this error names the situation. + """ + first_frame = _parse_single(data) + if first_frame is None: + return None + fin, opcode, payload, consumed = first_frame + + if opcode == OPCODE_CONTINUATION: + raise FrameError( + "a continuation frame arrived with no message in progress; " + "the stream is out of sync") + if fin: + return opcode, payload, consumed + + if opcode not in (OPCODE_TEXT, OPCODE_BINARY): + raise FrameError("a control frame cannot be fragmented") + + fragments = [payload] + total = consumed + while True: + nxt = _parse_single(data[total:]) + if nxt is None: + return None + nfin, nopcode, npayload, nconsumed = nxt + if nopcode != OPCODE_CONTINUATION: + raise FrameError( + "a control frame interleaved within a fragmented message " + "is not supported by this parser") + fragments.append(npayload) + total += nconsumed + if sum(len(f) for f in fragments) > MAX_PAYLOAD: + raise FrameError( + f"reassembled message exceeds the {MAX_PAYLOAD} limit") + if nfin: + return opcode, b"".join(fragments), total + + +def _parse_single(data: bytes) -> Optional[tuple[bool, int, bytes, int]]: + """One raw frame: ``(fin, opcode, payload, consumed)`` or None.""" + if len(data) < 2: + return None + + first, second = data[0], data[1] + if first & 0x70: + # RSV1..3 signal an extension that was never negotiated here. + raise FrameError("reserved bits set; no extension is supported") + fin = bool(first & 0x80) + opcode = first & 0x0F + masked = bool(second & 0x80) + length = second & 0x7F + offset = 2 + + if length == 126: + if len(data) < offset + 2: + return None + length = struct.unpack("!H", data[offset:offset + 2])[0] + offset += 2 + elif length == 127: + if len(data) < offset + 8: + return None + length = struct.unpack("!Q", data[offset:offset + 8])[0] + offset += 8 + + if length > MAX_PAYLOAD: + raise FrameError( + f"frame claims {length} bytes, over the {MAX_PAYLOAD} limit") + + if masked: + if len(data) < offset + 4: + return None + key = data[offset:offset + 4] + offset += 4 + else: + key = b"" + + if len(data) < offset + length: + return None + + payload = data[offset:offset + length] + if masked: + payload = bytes(b ^ key[i % 4] for i, b in enumerate(payload)) + + return fin, opcode, payload, offset + length diff --git a/src/eda_agent/core/backends.py b/src/eda_agent/core/backends.py index 04c11c9..c9a78a8 100644 --- a/src/eda_agent/core/backends.py +++ b/src/eda_agent/core/backends.py @@ -20,9 +20,52 @@ class BackendUnavailableError(RuntimeError): """The requested backend's tool is not reachable (not running, API off).""" +#: The backend whose tools were actually registered, once something has +#: registered them. Set by ``tools.register_backend``. +_REGISTERED: Optional[str] = None + + +def set_active_backend(name: str) -> None: + """Record which backend's tools were registered. + + THE REGISTRY AND THE RESOLVER USED TO DISAGREE. Tool registration + takes the backend as an argument, while this module read it back out + of the environment, so the two agreed only as long as one caller set + both. A harness that registered the EasyEDA surface without also + exporting the variable got EasyEDA tools and an ALTIUM resolver, and + ``review_design`` reviewed a completely different design while + reporting success. With Altium open at the time the answer looked + entirely plausible: real parts, real nets, wrong document. + + Recording it here removes the second source of truth rather than + asking every caller to remember the first. + """ + global _REGISTERED + _REGISTERED = (name or "").strip().lower() or None + + def active_backend_name() -> str: - """The backend the server started under (same resolution as the server).""" - return (os.environ.get("EDA_AGENT_BACKEND", "altium") or "altium").strip().lower() + """The configured backend, or failing that the registered one. + + THE ENVIRONMENT WINS WHEN IT IS SET, because it is the explicit + configuration and registration is only a record of what happened. + Preferring the registration instead made `_REGISTERED` sticky for + the life of the process: once anything had registered a backend it + overrode every later `EDA_AGENT_BACKEND`, which is how nine tests + that set the variable started reading the wrong guide text. + + The bug this still fixes is the opposite case, and it is the common + one: something registers a backend WITHOUT setting the variable, and + the resolver silently falls back to the default. A harness that did + that got EasyEDA tools over an Altium resolver, and review_design + returned a clean, plausible review of a different EDA's document. + """ + configured = (os.environ.get("EDA_AGENT_BACKEND") or "").strip().lower() + if configured: + return configured + if _REGISTERED: + return _REGISTERED + return "altium" class KiCadBackend: @@ -156,15 +199,188 @@ async def run_erc(self) -> dict[str, Any]: } -_BACKENDS = {"altium": AltiumBackend, "kicad": KiCadBackend} +class EasyEdaBackend: + """EasyEDA Pro, reached through its extension API. + + The editor dials out to this process rather than being driven by it, + so every method here reports the source as unreachable until the + extension connects. That is a different answer from "the command + failed", and the two must not be collapsed: one means start the + extension, the other means the edit was refused. + + DRC and ERC are run by the editor and read back, never reimplemented + here. This project does not reimplement an EDA tool's own checks, + for the same reason it does not synthesize Altium's binary formats: + a second opinion that disagrees with the tool is worse than no + opinion. + """ + + name = "easyeda" + + def _bridge(self): + from ..bridge.easyeda_bridge import ( + EasyEdaNotReachableError, + get_easyeda_bridge, + ) + try: + return get_easyeda_bridge(), EasyEdaNotReachableError + except Exception as exc: # noqa: BLE001 - surfaced as unavailable + raise BackendUnavailableError(str(exc)) from None + + @staticmethod + def _result(reply: dict, what: str) -> dict: + """The reply's result, refusing to read a REFUSAL as data. + + A REFUSED COMMAND IS NOT AN EMPTY DESIGN. The editor injects its + API per document type, so design.snapshot on a schematic comes + back as a considered error saying to open a PCB. Reading + ``reply["result"] or {}`` turned that into a snapshot of nothing, + and review_design then reported success with zero parts, zero + nets and zero findings: a clean bill of health for a board it + never looked at, on a design the audits could see 111 parts in. + + Raising keeps the two apart. "Nothing was examined" and "nothing + was wrong" are opposite answers and only one of them is safe to + act on. + """ + error = reply.get("error") + if error: + raise BackendUnavailableError(f"{what}: {error}") + result = reply.get("result") + if result is None: + raise BackendUnavailableError( + f"{what}: the editor answered with neither a result nor an " + f"error, so there is nothing to report and no reason why") + return result + + async def health(self) -> dict[str, Any]: + bridge, unreachable = self._bridge() + try: + return bridge.ping() + except unreachable as exc: + raise BackendUnavailableError(str(exc)) from None + + async def snapshot(self) -> DesignSnapshot: + bridge, unreachable = self._bridge() + try: + reply = bridge.send_editor_command("design.snapshot") + except unreachable as exc: + raise BackendUnavailableError(str(exc)) from None + + result = self._result(reply, "design.snapshot") + + # Translate the wire vocabulary into the snapshot's. The + # extension speaks EasyEDA's language ("designator"), the + # snapshot speaks its own ("refdes"), and passing the wire form + # through unchanged builds a snapshot with no identifiable parts + # at all. That failure is silent: no error, just a review that + # finds nothing on a board full of problems, which is worse than + # a crash because it reads as a clean bill of health. + parts = [ + { + "refdes": p.get("designator") or p.get("refdes") or "", + "value": p.get("value", ""), + "footprint": p.get("footprint", ""), + "layer": p.get("layer", ""), + } + for p in (result.get("parts") or []) + ] + pins = [ + { + "refdes": p.get("designator") or p.get("refdes") or "", + "pin": p.get("pin", ""), + "net": p.get("net", ""), + } + for p in (result.get("pins") or []) + ] + return DesignSnapshot.build( + "easyeda", parts, pins, + board_name=str(result.get("board_name") or ""), + unconnected_pad_count=int(result.get("unconnected_pads") or 0), + raw_stats={k: v for k, v in (result.get("stats") or {}).items() + if k in ("tracks", "vias", "zones", "stackup_layers", + "footprints", "pads")}, + ) + + @staticmethod + def _checked(result: dict, what: str) -> dict: + """A checker's findings, or a refusal saying nothing was checked. + + A CHECK THAT DID NOT RUN IS NOT A CHECK THAT PASSED. EasyEDA's + checkers sometimes answer with the bare boolean false instead of + a report. The extension recognises that and replies with + ``ran: false`` and a reason, but reading ``violations or []`` + past it turns "nothing was enumerated" into "no violations + found" and reports a clean board with a straight face. + + The extension already refuses to guess here; this stops the + refusal being discarded one layer up. + """ + if result.get("ran") is False: + return { + "ok": False, + "source": "easyeda", + "reason": str(result.get("failed") + or f"{what} did not run, and no reason was given"), + "ran": False, + } + violations = result.get("violations") or [] + return { + "ok": True, + "source": "easyeda", + "ran": True, + "violation_count": result.get("violation_count", len(violations)), + "violations": violations[:200], + } + + async def run_drc(self) -> dict[str, Any]: + bridge, unreachable = self._bridge() + try: + reply = bridge.send_editor_command("design.run_drc", timeout=120.0) + except unreachable as exc: + raise BackendUnavailableError(str(exc)) from None + return self._checked( + self._result(reply, "design.run_drc"), "design.run_drc") + + async def run_erc(self) -> dict[str, Any]: + bridge, unreachable = self._bridge() + try: + reply = bridge.send_editor_command("design.run_erc", timeout=120.0) + except unreachable as exc: + raise BackendUnavailableError(str(exc)) from None + return self._checked( + self._result(reply, "design.run_erc"), "design.run_erc") + + +_BACKENDS = { + "altium": AltiumBackend, + "easyeda": EasyEdaBackend, + "kicad": KiCadBackend, +} def resolve_backend(name: Optional[str] = None): """Return the adapter for ``name`` (or the active backend if None). - Under the ``both`` backend, or any unrecognised value, the default - (Altium) is used; pass an explicit name to target the other. + Under the ``both`` backend the default (Altium) is used; pass an + explicit name to target the other. + + A NAME THIS DOES NOT RECOGNISE IS REFUSED. It used to fall through + to Altium, which turns a misspelled backend into a full review of + whichever design Altium happens to have open: plausible parts, + plausible nets, wrong document, and a report that says nothing went + wrong. Reviewing the wrong design silently is worse than not + reviewing at all. """ key = (name or active_backend_name()).strip().lower() - cls = _BACKENDS.get(key, AltiumBackend) - return cls() + if key in _BACKENDS: + return _BACKENDS[key]() + if not name: + # No explicit request: an unrecognised ambient value, including + # "both", means take the default. + return AltiumBackend() + raise BackendUnavailableError( + f"unknown backend {name!r}. Valid names are " + f"{', '.join(sorted(_BACKENDS))}. Refusing rather than falling " + f"back, because the fallback would review a different design " + f"and report success") diff --git a/src/eda_agent/design/_wiring.py b/src/eda_agent/design/_wiring.py index c513e08..e9fa092 100644 --- a/src/eda_agent/design/_wiring.py +++ b/src/eda_agent/design/_wiring.py @@ -249,7 +249,7 @@ def _power_port_orientation(pin_orientation: int, is_ground: bool) -> int: def _label_justification(pin_orientation: int) -> int: """Justification for a net label at a pin's stub end. - HARD RULE (user, 2026-07-23): a net label on a LEFT-facing pin must + HARD RULE, set by the user: a net label on a LEFT-facing pin must read to the LEFT of the pin, never overlap it. The anchor stays on the stub (it is the electrical hotspot); justification decides which way the text grows. Left-facing pin (orientation 2) -> bottom-right diff --git a/src/eda_agent/design/autonomy.py b/src/eda_agent/design/autonomy.py index bf6a6b8..6c3c0ef 100644 --- a/src/eda_agent/design/autonomy.py +++ b/src/eda_agent/design/autonomy.py @@ -15,6 +15,8 @@ from __future__ import annotations +import re + from .session import STAGES from .state_machine import MAX_STAGE_ATTEMPTS, STAGE_PLAYBOOKS @@ -54,8 +56,271 @@ ] +_BACKEND_TOOLS: dict = {} + + +def _registered_tools(backend: str) -> set: + """Tool names the given backend registers, computed once per backend. + + Late import on purpose: eda_agent.tools imports this module, so a + module-level import is circular. Cached because registering the + whole surface is not free and the answer cannot change within a + process. + """ + if backend in _BACKEND_TOOLS: + return _BACKEND_TOOLS[backend] + + captured: dict = {} + + class _Mcp: + def tool(self, *a, **k): + def deco(fn): + captured[fn.__name__] = fn + return fn + return deco + + try: + from ..tools import register_backend + + register_backend(_Mcp(), backend) + except Exception: # noqa: BLE001 + # A guide naming every tool beats one naming none, so an + # unexpected registration failure falls back to no filtering. + _BACKEND_TOOLS[backend] = set() + return set() + _BACKEND_TOOLS[backend] = set(captured) + return _BACKEND_TOOLS[backend] + + +#: Altium tool -> the tool that does the same job on another backend. +#: Only entries VERIFIED to exist are useful, so a test asserts every +#: value is registered somewhere; a mapping to a tool nobody has is +#: worse than no mapping, because it reads as available. +_EQUIVALENTS = { + "pcb_place_components": "easyeda_place_pcb_components", + "pcb_move_components": "easyeda_snap_components_to_grid", + "proj_compare_sch_pcb": "easyeda_compare_schematic_pcb", + "design_execute_plan": "easyeda_run_plan", + "design_lint_report": "easyeda_review_board", + "design_audit_schematic": "easyeda_review_board", + "pcb_run_drc": "run_drc", + "proj_run_erc": "run_erc", + "lib_create_symbol": "easyeda_create_symbol", + "lib_search": "easyeda_search_devices", + "pcb_create_design_rule": "easyeda_create_net_class", + "pcb_modify_layer": "easyeda_modify_layer", + "pcb_place_tracks": "easyeda_add_polyline", + "pcb_place_via": "easyeda_add_via", + "pcb_start_polygon_placement": "easyeda_add_zone", + "proj_generate_fab_package": "easyeda_export_gerber", + "proj_export_step": "easyeda_export_3d", + # Named by the DISCIPLINE rules rather than the stage playbooks. + # Every one was checked against the live registry before being + # added; a proposed easyeda_add_polygon was rejected because no + # such tool exists, which is the check earning its place. + "lib_add_pins": "easyeda_add_pins", + "lib_add_symbol_rectangle": "easyeda_add_schematic_rectangle", + "lib_add_symbol_arc": "easyeda_add_arc", + "lib_add_symbol_lines": "easyeda_add_polyline", + "lib_add_footprint_pads": "easyeda_add_pads", + "lib_add_footprint_pad": "easyeda_add_pad", + "lib_add_footprint_tracks": "easyeda_add_polyline", + "lib_add_footprint_track": "easyeda_add_line", + "lib_create_footprint": "easyeda_create_footprint", + "design_validate": "design_validate_plan", + "app_set_active_document": "easyeda_open_document", + "app_checkpoint": "easyeda_checkpoint", + "obj_batch_modify": "easyeda_modify_pcb_components", + "sch_place_components": "easyeda_place_schematic_components", + "sch_place_wires": "easyeda_add_wires", + "sch_set_components_parameters": + "easyeda_set_schematic_component_properties", + # The one-call generators of rule 9. These were named in the text + # all along and went unmapped because they are written WITH their + # call signature, so the substitution never matched them and the + # gap was invisible: the rule read as adapted because the prose + # around it was. + "lib_create_standard_footprint": "easyeda_create_standard_footprint", + "lib_create_ic_symbol": "easyeda_create_ic_symbol", + "lib_create_passive_symbol": "easyeda_create_passive_symbol", + # Rule 15 lists five symbol-primitive helpers and four were mapped. + # The fifth was rejected earlier on the grounds that no equivalent + # existed, which was true of the name that was checked + # (easyeda_add_polygon) and false of the tool that exists. Checking + # a guessed name proves nothing when it comes back absent; this one + # was found by searching the registry instead. + "lib_add_symbol_polygon": "easyeda_add_schematic_polygon", + # Read-side tools. A planner that cannot read the board state it is + # about to change is the failure these prevent. + "pcb_get_components": "easyeda_get_components", + "pcb_check_placement_collision": "easyeda_audit_placement_collisions", + "proj_get_nets": "easyeda_get_nets", + "proj_get_unconnected_pins": "easyeda_get_unconnected_pins", + "obj_crossref_net": "easyeda_cross_probe", + # Both renders map to the same tool: EasyEDA draws whichever + # document is open rather than offering one per editor. + "sch_render_svg": "easyeda_render_image", + "pcb_render_svg": "easyeda_render_image", + # design_visual_review renders through the Altium bridge, so on + # this backend the equivalent is the editor's own render. Two + # stages, placement and verification, were telling the caller to + # run a tool that does not exist here. + "design_visual_review": "easyeda_render_image", + # The placement solver is EDA-agnostic; only the reading and + # writing differed, and the EasyEDA plumbing now exists. Without + # this mapping the placement stage names a tool the backend lacks, + # which is the whole stage. + "pcb_plan_placement": "easyeda_plan_placement", + # The grid router is EDA-agnostic too; only the geometry fetch + # differed. route_plan_repairs is NOT mapped and must not be: it + # reads a paired-primitive DRC shape EasyEDA does not report, so a + # repair plan there escalates everything and looks like a working + # tool. See task #48. + "route_plan": "easyeda_route_plan", + # The schematic-to-board update, which is the whole of the + # sch_to_pcb stage. EasyEDA calls it importChanges and the tool has + # existed all along; nothing connected the two, so the stage + # reported its only tool as absent on this backend and read as + # impossible when it was merely unmapped. + "pcb_build_from_project": "easyeda_import_schematic_changes", + # Stitching is geometry plus one via call, and both existed. The + # pours_tuning stage named the Altium tool and reported it absent. + "pcb_place_stitching_vias": "easyeda_place_stitching_vias", + # A power port is a net flag here, not a sheet port; net ports are + # EasyEDA's cross-sheet connector and would be the wrong glyph. + "sch_place_power_port": "easyeda_create_net_flag", + "sch_place_net_label": "easyeda_create_net_label", +} + + +def _stage_tools(stage: str, available: set) -> tuple: + """(tools you can call here, tools this stage wants but lacks). + + The playbooks were written against Altium and name Altium tools. + On EasyEDA 33 of the 50 named across the 13 stages are not + registered, and six stages name nothing that exists there, so an + agent following the guide was told to call tools it does not have. + Naming what is absent, rather than quietly dropping it, keeps a + thin stage legible: "there is no tool for this here" is guidance, + a silently empty list is a puzzle. + """ + wanted = STAGE_PLAYBOOKS[stage]["tools"] + if not available: # filtering unavailable + return list(wanted), [] + usable, absent = [], [] + for tool in wanted: + if tool in available: + usable.append(tool) + continue + # The same job under another name is still the job. Only + # substitute a tool this backend really registers. + swap = _EQUIVALENTS.get(tool) + if swap and swap in available and swap not in usable: + usable.append(swap) + elif not swap or swap not in available: + absent.append(tool) + return usable, absent + + +def _adapt_lines(lines, backend: str, available: set) -> list: + """Swap Altium tool names in guidance TEXT for the local ones. + + Safe here for the same measured reason it is safe in the discipline + document: neither the loop protocol nor the hard constraints + contains a sentence explaining that a tool is unavailable, so every + mention is a plain instruction where the equivalent reads + correctly. Checked with seven phrasings, all absent. + + A name with no equivalent is LEFT ALONE rather than deleted: a + sentence with a hole in it is worse than one naming a tool the + reader will discover they lack, and the stage entries already + report absences explicitly. + + The ``backend == "altium"`` test below is belt-and-braces, and + honestly so: mutation-testing it produced an EQUIVALENT mutant, + because no mapping whose replacement exists on Altium has a key + appearing in this prose, so removing the test changes nothing + today. It stays because that is a property of the current table, + not of the code, and the next entry could break it silently. + """ + if backend == "altium" or not available: + return list(lines) + out = [] + for line in lines: + for altium_tool, swap in _EQUIVALENTS.items(): + if swap in available and altium_tool in line: + # Word-bounded, because a plain replace rewrites the + # FRONT of a longer name. "design_validate" is a key + # and "design_validate_plan" is a real tool, so a + # substring swap turned an exit gate into + # "design_validate_plan_plan": a name nothing + # registers, which then collected a "(not available on + # this backend)" annotation and told the client its own + # working tool was missing. Two more pairs in the table + # (footprint pad/pads, track/tracks) are only safe + # today by dict ordering, which is not a property worth + # relying on. + line = re.sub(rf"\b{re.escape(altium_tool)}\b", swap, line) + # A step whose tools are ALL missing is not merely naming + # something unavailable, it is advising a capability that does + # not exist here: the long-run step tells a client to start a + # background job and poll it, and EasyEDA has no job system. + # Saying so beats leaving advice that cannot be taken. + # STAGE names look like tool names and are not tools. Step 5 + # lists sch_to_pcb, routing and pours_tuning as stages to + # checkpoint before; reading sch_to_pcb as an absent tool + # annotated a step whose actual tool, easyeda_checkpoint, is + # right there in the sentence. The same trap caught the docs + # guard earlier. + named = {n for n in _TOOL_NAME.findall(line) + if n.startswith(_TOOL_PREFIXES) and n not in STAGES} + if named and not (named & available): + line += " (not available on this backend)" + out.append(line) + return out + + +_TOOL_NAME = re.compile(r"\b([a-z]+_[a-z0-9_]+)\b") +_TOOL_PREFIXES = ("lib_", "pcb_", "sch_", "proj_", "obj_", "app_", + "design_", "audit_", "easyeda_", "kicad_") + + +def _stage_entry(stage: str, available: set, backend: str = "") -> dict: + """One stage of the playbook, adapted to the backend asking. + + The goal and the exit gate go through the same adaptation as the + loop protocol. They were skipped before, and the omission was easy + to miss because the tools list beside them WAS adapted: a stage + read as fully translated while its exit gate still told an EasyEDA + client to wait on an Altium tool. An exit gate is the sentence that + decides when a stage is finished, so naming a tool the client + cannot call is the one place a wrong name stalls the run. + """ + usable, absent = _stage_tools(stage, available) + play = STAGE_PLAYBOOKS[stage] + goal, gate = _adapt_lines( + [play["goal"], play["exit_gate"]], backend or "altium", available) + entry = { + "stage": stage, + "goal": goal, + "tools": usable, + "exit_gate": gate, + } + if absent: + entry["tools_not_on_this_backend"] = absent + if not usable: + entry["note"] = ( + "no tool for this stage is registered on this backend; do " + "the equivalent by hand in the editor, or skip the stage") + return entry + + def autonomy_guide() -> dict: """The full autonomous-design protocol as structured data.""" + from ..core.backends import active_backend_name + + backend = active_backend_name() + available = _registered_tools(backend) return { "overview": ( "Drive a full spec-to-board design by looping the state machine: " @@ -63,15 +328,10 @@ def autonomy_guide() -> dict: "until complete or blocked. The server owns sequencing and gates, " "so you never memorize the workflow." ), - "loop": LOOP_PROTOCOL, + "loop": _adapt_lines(LOOP_PROTOCOL, backend, available), + "backend": backend, "stages": [ - { - "stage": st, - "goal": STAGE_PLAYBOOKS[st]["goal"], - "tools": STAGE_PLAYBOOKS[st]["tools"], - "exit_gate": STAGE_PLAYBOOKS[st]["exit_gate"], - } - for st in STAGES + _stage_entry(st, available, backend) for st in STAGES ], "constraints": HARD_CONSTRAINTS, "resume": ( diff --git a/src/eda_agent/design/discipline.py b/src/eda_agent/design/discipline.py index fdc9e8d..e93a343 100644 --- a/src/eda_agent/design/discipline.py +++ b/src/eda_agent/design/discipline.py @@ -116,8 +116,8 @@ F# fuses, FB# ferrites. Number from 1 per refdes-letter, no gaps. 10. **Sheets default to one called "main".** Multiple sheets only when - the spec obviously needs sectioning (>30 parts, or distinct - functional blocks). + the spec needs sectioning (>30 parts, or distinct functional + blocks). 11. **Zones are optional** placement guidance for the executor. Use them to cluster decoupling near its IC, separate analog from digital, etc. @@ -599,7 +599,7 @@ nets with `is_power` / `is_ground` set are exempt because the power port carries the connection. -## PCB placement discipline (post-ECO, layout phase) +## PCB placement discipline (once the netlist is on the board) Once parts are on the PCB, moving them is a separate concern from the DesignPlan executor above. The same agent often drives both phases. @@ -653,13 +653,152 @@ """ +#: What runs a plan, per backend. The discipline text was written for +#: Altium and says so in its opening paragraph; on another backend that +#: sentence names the wrong editor AND the wrong tool, which is the +#: first thing a planner reads. +_EXECUTOR = { + "altium": ("Altium Designer", "design_execute_plan"), + "easyeda": ("EasyEDA Pro", "easyeda_emit_plan then easyeda_run_plan"), + "kicad": ("KiCad", "design_execute_plan"), +} + +#: The first line of the schematic-to-PCB block, used as an anchor. The +#: block runs from here to the start of rule 8. +_ECO_ANCHOR = "6. **ECO (schematic → PCB) is not headless.**" +_RULE_8_ANCHOR = "8. **Connectivity review uses the netlist, never the render.**" + +#: Rules 6 and 7 explain Altium's Engineering Change Order: a dialog a +#: human must click, and the trick for populating a board without it. +#: Every sentence is about a mechanism only Altium has, so swapping the +#: tool names produces the worst possible result: an EasyEDA tool name +#: wrapped in Altium mechanics, which reads as authoritative and +#: describes nothing that exists. The block is replaced wholesale +#: instead. +#: +#: What replaces it says only what has been measured. Whether these +#: editors raise a dialog for the transfer has NOT been checked on a +#: live session, so the text says to treat it as attended rather than +#: guessing either way; claiming it is headless would be inventing a +#: capability, and claiming it is modal would be inventing a +#: limitation. +_SCH_TO_PCB_BLOCK = { + "easyeda": """6. **Schematic to PCB transfer is `easyeda_import_schematic_changes`.** + Whether the editor raises a dialog for it has not been verified on a + live session, so treat the call as attended: do not put it in an + unattended run until someone has watched it once and recorded what + happened. + +7. **Placing a footprint is not the same as connecting it.** + `easyeda_place_pcb_components` puts geometry on the board. Do not + assume a placed part is a connected one: confirm with + `easyeda_compare_schematic_pcb`, and read the remaining opens with + `easyeda_get_unconnected_pins` before treating the transfer as done. +""", + "kicad": """6. **Schematic to PCB transfer is `kicad_generate_pcb`.** + Whether it prompts has not been verified here, so treat the call as + attended until it has been. + +7. **Placing a footprint is not the same as connecting it.** Confirm + the board matches the schematic with `kicad_compare_sch_pcb`, and + read the remaining opens with `kicad_get_unconnected_pins`, rather + than assuming a placed part is a connected one. +""", +} + + def get_discipline() -> str: - """Return the discipline doc + the embedded DesignPlan JSON schema.""" + """Return the discipline doc + the embedded DesignPlan JSON schema. + + The opening paragraph is rewritten for the active backend. Only + that paragraph: the rest of the text names Altium tools inside + sentences that sometimes EXPLAIN why a tool is Altium-only, and + substituting there would produce prose contradicting itself. That + wider split is task #58; this fixes the sentence a planner reads + first, which otherwise tells an EasyEDA user their plan is going + into Altium. + """ + from ..core.backends import active_backend_name + schema_obj = DesignPlan.model_json_schema() schema_blob = json.dumps(schema_obj, indent=2) + backend = active_backend_name() + editor, executor = _EXECUTOR.get(backend, _EXECUTOR["altium"]) + text = _DISCIPLINE + if backend != "altium": + # Substitute the tool names too, not just the framing. This is + # safe HERE and was checked rather than assumed: the document + # contains no sentence explaining that a tool is unavailable + # ("not offered", "Altium-only", "does not exist" and four more + # phrasings all return nothing), so every reference is a plain + # "use X to do Y" instruction where the equivalent reads + # correctly. The same substitution over autonomy.py's prose, + # which DOES explain unavailability, would produce text + # contradicting itself; that is still task #58. + # + # Only backticked names are touched, and only where the + # replacement is a tool this backend registers. + from .autonomy import _EQUIVALENTS, _registered_tools + + # Two spellings, because the document uses both and only one + # was being caught. A name written with its call signature, + # `lib_create_standard_footprint(name, family, ...)`, is inside + # a backtick span but is not followed by one, so matching on + # the closing backtick alone skipped every worked example: the + # three one-call generators in rule 9 all survived untouched + # while the prose around them was adapted. Matching the opening + # parenthesis as well reaches them. Both forms keep a delimiter + # after the name, which is what stops a shorter key rewriting + # the front of a longer name: `lib_add_footprint_pad` and + # `lib_add_footprint_pads` are both real and both mapped. + # + # Naming the shorter-name hazard with an INVENTED example here + # broke a guard that scans this file for tool-shaped names and + # correctly reported it as a reference to a tool that does not + # exist. A comment in this file is part of the surface that + # guard reads, so examples in it have to be real. + available = _registered_tools(backend) + for altium_tool, swap in _EQUIVALENTS.items(): + if swap in available: + text = text.replace(f"`{altium_tool}`", f"`{swap}`") + text = text.replace(f"`{altium_tool}(", f"`{swap}(") + + # Rules 6 and 7 are replaced wholesale rather than translated. + # Slicing between two anchors that contain no tool names means + # the substitution above cannot have moved them, whichever + # order these two steps run in. + block = _SCH_TO_PCB_BLOCK.get(backend) + start = text.find(_ECO_ANCHOR) + end = text.find(_RULE_8_ANCHOR) + if block and 0 <= start < end: + text = text[:start] + block + "\n" + text[end:] + elif block: + # The anchors moved. Saying so beats shipping the Altium ECO + # rules to a backend that has no ECO, which is what a silent + # miss would do. + text += ( + "\n\n> NOTE: rules 6 and 7 describe Altium's Engineering " + "Change Order, which this backend does not have, and they " + "could not be replaced automatically. Ignore them here.\n") + + target = "the executor can instantiate in Altium Designer." + replaced = text.replace( + target, + f"the executor can instantiate in {editor} (via {executor}).", + 1) + if replaced == text: + # A silent no-op is the failure mode here: the planner + # would read the Altium framing believing it was corrected. + # Say so in the text rather than pretending. + replaced = text + ( + f"\n\n> NOTE: this document was written for Altium and " + f"its opening could not be adapted. The active backend " + f"is {editor}; a plan is run there with {executor}.\n") + text = replaced + return ( - _DISCIPLINE + text + "\n## DesignPlan JSON schema\n\nYour DesignPlan must validate " + "against this schema:\n\n```json\n" + schema_blob diff --git a/src/eda_agent/design/easyeda_emitter.py b/src/eda_agent/design/easyeda_emitter.py new file mode 100644 index 0000000..9e614d3 --- /dev/null +++ b/src/eda_agent/design/easyeda_emitter.py @@ -0,0 +1,445 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Turn a validated DesignPlan into an ordered list of EasyEDA calls. + +WHY THIS IS NOT AN EXECUTOR. ``design/executor.py`` drives Altium +directly, and it names Altium bridge commands throughout, so it has no +seam an EasyEDA backend could be dropped into. Rather than abstract a +1100-line module that works, this follows the pattern +``lib_easyeda_import`` already uses: produce the ordered sequence of +this server's own tool calls and hand it back. + +The consequence is the useful part. The sequence is data, so it can be +read, diffed and validated before anything touches a design, and the +Altium path is untouched by anything here. + +WHAT IT DELIBERATELY DOES NOT DO. It never picks a library part. Altium +resolves a symbol by name; EasyEDA needs the ``{libraryUuid, uuid}`` +pair a search returns, and a search for an MPN can come back with +several. Choosing one silently is how a board ends up with the wrong +footprint under a BOM line that reads correctly, so an unresolved part +becomes a search step plus an explicit hole in the plan, and emitting +stops short of pretending the design is placeable. + +UNITS. Every coordinate here is in mils, matching the layout engine and +the rest of this project. The conversion to EasyEDA's schematic units +belongs to the tool layer, in ``MILS_PER_SCHEMATIC_UNIT``, and must not +be repeated here: applying it twice is a hundredfold error that still +draws a plausible-looking schematic. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Optional + +from ._wiring import _is_ground_net, _net_representation +from .plan import DesignPlan, PartStatus + +__all__ = [ + "EmittedCall", + "EasyEdaPlan", + "emit_easyeda_plan", + "emit_easyeda_connections", +] + + +@dataclass +class EmittedCall: + """One tool call, with why it is here.""" + + tool: str + arguments: dict[str, Any] + #: What this step is for, in one line, for a human reading the plan. + purpose: str + + def to_dict(self) -> dict[str, Any]: + return { + "tool": self.tool, + "arguments": self.arguments, + "purpose": self.purpose, + } + + +@dataclass +class EasyEdaPlan: + """The emitted sequence, plus what stopped it being complete.""" + + calls: list[EmittedCall] = field(default_factory=list) + #: Parts with no library uuid and uuid pair yet. Each one is a + #: search step in ``calls``; this list is what the caller must + #: resolve before the sequence can run. + #: + #: EITHER SPELLING IS ACCEPTED. ``lib.search_devices`` answers with + #: ``libraryUuid`` and this emitter was written against + #: ``library_uuid``, so feeding a search result straight back left + #: every part unresolved even though the documented flow chains + #: exactly those two steps. See ``_library_uuid``. + unresolved_parts: list[dict[str, Any]] = field(default_factory=list) + #: Reasons the plan cannot be run as emitted. Non-empty means do not + #: run it. + blockers: list[str] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + #: Nets this sequence does NOT connect, and what connects them. + #: + #: Placement can be emitted from a plan alone; a wire is drawn at a + #: pin, and pin coordinates only exist once the symbols are placed. + #: So a full schematic takes two passes, and the first reports + #: `runnable` true with every net still missing. Carried as data so + #: a caller can see the sequence is a stage rather than the whole + #: job without having to read the note. + nets_pending: int = 0 + next_step: Optional[str] = None + + @property + def runnable(self) -> bool: + return not self.blockers and not self.unresolved_parts + + @property + def complete(self) -> bool: + """Runnable AND nothing deferred to a later pass.""" + return self.runnable and not self.nets_pending + + def to_dict(self) -> dict[str, Any]: + return { + "runnable": self.runnable, + "complete": self.complete, + "calls": [c.to_dict() for c in self.calls], + "unresolved_parts": self.unresolved_parts, + "blockers": self.blockers, + "nets_pending": self.nets_pending, + "next_step": self.next_step, + "notes": self.notes, + } + + +def _library_uuid(ref: dict) -> Optional[str]: + """The library uuid from a resolution entry, either spelling. + + ``lib.search_devices`` answers with ``libraryUuid``, because that is + what the editor's own API returns. This emitter asked for + ``library_uuid``. The documented flow chains the two directly, emit + a search step, run it, feed the result back as a resolution, and it + did not join: a caller who passed the search result through + unchanged had every part reported as unresolved. + + Both spellings are accepted, snake_case first since it matches the + argument the place call takes. Converting at the boundary beats + making every caller rename a field the editor chose. + """ + return ref.get("library_uuid") or ref.get("libraryUuid") + + +def _search_terms(part: Any) -> Optional[str]: + """What to search the EasyEDA library for, for one part. + + MPN first: it identifies one physical part, and it is what the + atomic-parts contract requires every existing part to carry. A + library reference is a name in somebody's library and can match + several things, so it is a fallback rather than a choice. + """ + if getattr(part, "mpn", None) and part.mpn.strip(): + return part.mpn.strip() + if getattr(part, "lib_ref", None) and part.lib_ref.strip(): + return part.lib_ref.strip() + return None + + +def emit_easyeda_plan( + plan: DesignPlan, + *, + placements: Optional[dict[str, tuple[float, float, float]]] = None, + resolved_parts: Optional[dict[str, dict[str, str]]] = None, +) -> EasyEdaPlan: + """Emit the EasyEDA call sequence for ``plan``. + + Args: + plan: a validated DesignPlan. + placements: refdes -> (x_mils, y_mils, rotation). Computed by + ``compute_layout`` when the caller has run it. Parts with no + placement are reported rather than dropped at the origin, + where they would stack invisibly on top of each other. + resolved_parts: refdes -> {"library_uuid": ..., "uuid": ...} for + parts already looked up. Anything absent gets a search step + and is listed as unresolved. + + Returns: + An EasyEdaPlan. ``runnable`` is false whenever anything was left + undecided, and the sequence should not be run in that state. + """ + out = EasyEdaPlan() + placements = placements or {} + resolved_parts = resolved_parts or {} + + cross = plan.cross_check() + if cross: + out.blockers.extend(cross) + return out + + needs_creation = [p.refdes for p in plan.parts + if p.status == PartStatus.NEEDS_CREATION] + if needs_creation: + # Same refusal the Altium executor makes, for the same reason: a + # partially placed design reads as a finished one. + out.blockers.append( + "plan contains parts that need creating " + f"({', '.join(sorted(needs_creation))}); emitting a sequence " + "that places the rest would produce a design that looks " + "complete and is not") + return out + + out.calls.append(EmittedCall( + tool="easyeda_ping", + arguments={}, + purpose="confirm an editor is connected before changing anything", + )) + + for part in plan.parts: + ref = resolved_parts.get(part.refdes) + + # A RESOLUTION THAT DID NOT PARSE IS NOT AN UNRESOLVED PART. + # + # An entry present under the wrong keys used to fall through to + # the search branch, so a caller who HAD looked the part up was + # told to look it up again, with nothing saying why. The keys + # here are snake_case, and this file described them both ways: + # the field comment above said {libraryUuid, uuid} and the + # docstring said {library_uuid, uuid}. Following the wrong one + # cost a silent downgrade. + if ref and not (_library_uuid(ref) and ref.get("uuid")): + out.unresolved_parts.append({ + "refdes": part.refdes, + "reason": ( + f"resolved_parts has an entry for {part.refdes} but it " + f"carries {sorted(ref)} instead of a library uuid " + f"(library_uuid or libraryUuid) and uuid, so it " + f"could not be used"), + }) + continue + + if ref and _library_uuid(ref) and ref.get("uuid"): + placement = placements.get(part.refdes) + if placement is None: + out.unresolved_parts.append({ + "refdes": part.refdes, + "reason": "no placement was computed for this part", + }) + continue + x, y, rotation = placement + out.calls.append(EmittedCall( + tool="easyeda_place_schematic_component", + arguments={ + "library_uuid": _library_uuid(ref), + "uuid": ref["uuid"], + "x": x, + "y": y, + "rotation": rotation, + }, + purpose=f"place {part.refdes} ({part.value or part.lib_ref})", + )) + continue + + terms = _search_terms(part) + if terms is None: + out.unresolved_parts.append({ + "refdes": part.refdes, + "reason": "no mpn and no lib_ref to search for", + }) + continue + + out.calls.append(EmittedCall( + tool="easyeda_search_devices", + arguments={"query": terms}, + purpose=f"find a library part for {part.refdes} ({terms})", + )) + out.unresolved_parts.append({ + "refdes": part.refdes, + "search": terms, + "reason": "no library uuid pair yet; run the search and choose", + }) + + if out.unresolved_parts: + out.notes.append( + f"{len(out.unresolved_parts)} of {len(plan.parts)} parts are " + "not placeable yet. Resolve each one to a " + "{library_uuid, uuid} pair and emit again; nothing here picks " + "among search results, because a wrong pick produces a board " + "that is wrong in a way the BOM does not show.") + + # Connectivity cannot be emitted alongside placement, and saying so + # is better than emitting coordinates that would be guesses. A wire + # or a net label is drawn AT a pin, and a pin's position is not + # known until its symbol is placed and the editor is asked where its + # pins landed. Nothing in the plan carries that: the layout engine + # positions parts, not pins. + count = len(plan.nets) + out.notes.append( + f"{count} net{'' if count == 1 else 's'} " + f"{'is' if count == 1 else 'are'} not in this sequence. Connecting " + "them is a second pass: place the parts, read the pin coordinates " + "back with easyeda_get_schematic_pins, then emit wires and labels " + "against real positions. Emitting them now would mean inventing " + "coordinates, and a schematic wired to the wrong points still " + "looks like a schematic.") + + # SAY IT IN DATA, not only in prose. + # + # `runnable` is the flag a caller branches on, and it is true here + # while every net is still missing. That is correct for what this + # pass is, and it reads as "the sequence is complete" to anything + # that does not also parse the note. Running it and stopping leaves + # parts placed and nothing wired, which looks like a finished + # schematic and is not one. + out.nets_pending = count + out.next_step = ( + "easyeda_emit_connections" if count else None) + + return out + + +def emit_easyeda_connections( + plan: DesignPlan, + pin_positions: dict[tuple[str, str], tuple[float, float]], +) -> EasyEdaPlan: + """Emit the calls that connect a plan's nets, given real pin points. + + The second pass. Placement can be emitted from the plan alone, but a + wire or a label is drawn AT a pin, and a pin's position only exists + once its symbol is on the sheet. So this takes the positions read + back from the editor rather than deriving them, and a net with a + missing pin is reported instead of connected to a guess. + + How each net is drawn comes from ``_net_representation``, the same + rule the Altium path uses, imported rather than restated. Two + backends that decide this separately would drift, and the drift + would show up as one tool drawing labels where the other draws + wires, on the same plan. + + Args: + plan: the validated DesignPlan. + pin_positions: (refdes, pin) -> (x_mils, y_mils), in MILS. What + the editor reports is in schematic units of ten mils, so a + caller passing those through unconverted puts every wire a + tenth of the way to where it belongs. + + Returns: + An EasyEdaPlan whose calls connect the nets. ``blockers`` names + any net that could not be drawn. + """ + out = EasyEdaPlan() + + refdes_to_zone = {p.refdes: p.zone for p in plan.parts} + + for net in plan.nets: + points: list[tuple[str, float, float]] = [] + missing: list[str] = [] + for pin in net.pins: + position = pin_positions.get((pin.refdes, pin.pin)) + if position is None: + missing.append(f"{pin.refdes}.{pin.pin}") + continue + points.append((pin.refdes, position[0], position[1])) + + if missing: + # Drawing the pins that ARE known would produce a net that + # looks connected and is not, which survives review. + out.blockers.append( + f"net {net.name}: no position for " + f"{', '.join(missing)}; nothing drawn for this net") + continue + + representation = _net_representation(net, refdes_to_zone) + + if representation == "port": + kind = "Ground" if _is_ground_net(net) else "Power" + for _refdes, x, y in points: + out.calls.append(EmittedCall( + tool="easyeda_create_net_flag", + arguments={"name": net.name, "x": x, "y": y, + "kind": kind}, + purpose=f"{kind.lower()} rail glyph on {net.name}", + )) + continue + + if representation == "label_per_pin": + for _refdes, x, y in points: + out.calls.append(EmittedCall( + tool="easyeda_create_net_label", + arguments={"name": net.name, "x": x, "y": y}, + purpose=f"label {net.name} at a pin it crosses to", + )) + continue + + # A wire. Drawn pin to pin in the order the net lists them, + # which is the plan's order rather than a shortest path: routing + # is not this function's job, and a net named by the planner in + # signal order reads correctly drawn that way. + previous: Optional[list[list[float]]] = None + for (from_ref, x1, y1), (to_ref, x2, y2) in zip(points, points[1:]): + # A ZERO LENGTH WIRE IS NOT A CONNECTION, IT IS A SYMPTOM. + # + # Two pins at one coordinate means the symbols are on top of + # each other, which is a placement fault. Drawing a wire + # from a point to itself adds an invisible primitive that + # cannot be selected and hides the real problem, so the + # overlap is reported instead. + if x1 == x2 and y1 == y2: + out.notes.append( + f"net {net.name}: {from_ref} and {to_ref} are at the " + f"same point ({x1}, {y1}), so no wire was drawn. Two " + f"pins in one place means the parts are placed on top " + f"of one another; fix the placement rather than the " + f"wiring.") + previous = None + continue + + route = _orthogonal(x1, y1, x2, y2, previous=previous) + previous = route + out.calls.append(EmittedCall( + tool="easyeda_add_wire", + arguments={"points": route, "net": net.name}, + purpose=f"wire {net.name} from {from_ref} to {to_ref}", + )) + + return out + + +def _orthogonal(x1: float, y1: float, x2: float, y2: float, *, + previous: "Optional[list[list[float]]]" = None, + ) -> list[list[float]]: + """Pin to pin as horizontal and vertical runs, never a diagonal. + + A two-point segment between pins that share neither coordinate is a + DIAGONAL wire. Schematics are drawn on the square: every reader + expects horizontal and vertical runs, a diagonal reads as a mistake + even where the editor accepts it, and this project holds its + schematics to what a person would draw by hand. + + Aligned pins keep their single straight segment. Everything else + gets one elbow, horizontal first by default. That default is + arbitrary between the two L shapes and is fixed rather than chosen + per net, so two runs of one plan draw the same schematic. + + IT FLIPS TO VERTICAL FIRST WHEN HORIZONTAL WOULD RETRACE. A net of + three or more pins is wired as a chain, so each wire starts where + the last one ended. On a three pin net whose first two pins share a + row, the horizontal first elbow sent the branch back along the wire + just drawn, laying a second line on top of it before turning off. + Doubled copper is not what it looks like on a schematic; it looks + like one wire, and the drawing quietly stops matching what was + emitted. + """ + if x1 == x2 or y1 == y2: + return [[x1, y1], [x2, y2]] + + horizontal_first = [[x1, y1], [x2, y1], [x2, y2]] + if previous and len(previous) >= 2: + last = previous[-2:] + (px1, py1), (px2, py2) = last[0], last[1] + # The previous run ends horizontally at our starting height, and + # our first leg would travel back along it. + retraces = (py1 == py2 == y1 + and min(px1, px2) <= x2 <= max(px1, px2)) + if retraces: + return [[x1, y1], [x1, y2], [x2, y2]] + return horizontal_first diff --git a/src/eda_agent/design/footprint_policy.py b/src/eda_agent/design/footprint_policy.py index ca1dfe3..c178e95 100644 --- a/src/eda_agent/design/footprint_policy.py +++ b/src/eda_agent/design/footprint_policy.py @@ -79,11 +79,40 @@ def _dominant(values) -> Optional[Any]: # layer, whatever it has been renamed to. Compared with spaces stripped and # case folded, so "Top Overlay" and "TopOverlay" are the same layer. _STANDARD_LAYERS = frozenset({ + # Altium's names. "toplayer", "bottomlayer", "multilayer", "topoverlay", "bottomoverlay", "toppaste", "bottompaste", "topsolder", "bottomsolder", "keepoutlayer", "drillguide", "drilldrawing", + # EasyEDA's names, MEASURED from pcb.layers on a live + # 92-layer board rather than guessed. Only the two copper layers + # happen to normalise to the same string as Altium's; every other + # name differs, so this audit saw a standard EasyEDA stackup as a + # pile of non-standard layers. + # + # The inner layers cannot be listed. EasyEDA reports them with + # type SIGNAL and whatever name the user gave them, measured as + # "Int1 (GND)" and "Inner7" on that board, so a name alone cannot + # say they are standard. _STANDARD_PREFIXES below catches Altium's + # midlayer convention and nothing catches EasyEDA's, which is a + # known limit rather than an oversight: the type field says it and + # this function only receives a name. + "topsilkscreenlayer", "bottomsilkscreenlayer", + "topsoldermasklayer", "bottomsoldermasklayer", + "toppastemasklayer", "bottompastemasklayer", + "topassemblylayer", "bottomassemblylayer", + "boardoutlinelayer", "multi-layer", "documentlayer", + "mechanicallayer", + # The rest of the measured stackup. These are standard EasyEDA + # layers a footprint can legitimately draw on, and leaving them out + # made the audit report a perfectly ordinary part as using thirteen + # non-standard layers. + "holelayer", "componentshapelayer", "componentmarkinglayer", + "pinsolderinglayer", "pinfloatinglayer", "componentmodellayer", + "3dshelloutlinelayer", "3dshelltoplayer", "3dshellbottomlayer", + "drilldrawinglayer", "ratlinelayer", + "topstiffenerlayer", "bottomstiffenerlayer", }) _STANDARD_PREFIXES = ("midlayer", "internalplane") diff --git a/src/eda_agent/design/impedance_sizing.py b/src/eda_agent/design/impedance_sizing.py index 8ba5384..79371f6 100644 --- a/src/eda_agent/design/impedance_sizing.py +++ b/src/eda_agent/design/impedance_sizing.py @@ -30,7 +30,8 @@ import math from dataclasses import dataclass -_OZ_TO_MILS = 1.378 +from ..units import OZ_TO_MILS as _OZ_TO_MILS + _GEOMETRIES = ("microstrip", "microstrip_diff", "stripline", "stripline_diff") @@ -130,10 +131,68 @@ def trace_width_for_impedance( spacing_mils=spacing_mils, feasible=feasible) +def impedance_validity(z0: float, width_mils: float, + dielectric_height_mils: float, + dielectric_constant: float) -> dict: + """Whether a computed Z0 can be trusted, and why not. + + Lives in the engine because the tool layer has TWO copies of the + impedance tool, one in tools/calc.py serving KiCad and EasyEDA and + one in tools/pcb.py serving Altium. Guarding only the first left + Altium still returning a negative impedance, which is exactly the + drift a shared helper prevents. + + Returns ``{"usable": bool, "reason": str|None, + "outside_validity_range": bool, "warning": str|None, + "width_to_height_ratio": float}``. + + NOT USABLE when Z0 comes out at or below half an ohm. Both closed + forms are a logarithm of (dielectric height over conductor width), + so a wide trace on a thin dielectric drives the argument past 1 and + the result through zero into negative. A negative impedance is + visibly wrong; the small positive value just before it is the + more dangerous case, because it reads as a badly matched trace + rather than as a formula out of range. + + OUTSIDE THE RANGE, but still returned, when w/h or er falls outside + the band IPC-2141 states these expressions over. Inside it the usual + plus or minus ten percent applies; outside, the error grows and the + answer still arrives as a tidy number. + """ + h = float(dielectric_height_mils) + ratio = float(width_mils) / h if h > 0 else float("inf") + er = float(dielectric_constant) + out = { + "usable": True, + "reason": None, + "outside_validity_range": False, + "warning": None, + "width_to_height_ratio": round(ratio, 3), + } + if z0 <= 0.5: + out["usable"] = False + out["reason"] = ( + f"the closed form gives {z0:.2f} ohms for w/h = {ratio:.2f}, " + f"which is not a physical impedance. The IPC-2141 expressions " + f"are logarithmic in (dielectric height / trace width) and " + f"break down once the trace is wide relative to the " + f"dielectric. Use a field solver for this geometry.") + return out + if ratio > 2.0 or ratio < 0.1 or er < 1.0 or er > 15.0: + out["outside_validity_range"] = True + out["warning"] = ( + f"w/h is {ratio:.2f} and er is {er}. The IPC-2141 closed form " + f"is stated for 0.1 to 2.0 and er 1 to 15, so this figure is " + f"an extrapolation rather than the usual +/-10 percent. " + f"Confirm with a field solver before committing to a stackup.") + return out + + __all__ = [ "ImpedanceWidthResult", "z0_microstrip", "z0_stripline", "diff_coupling_factor", "trace_width_for_impedance", + "impedance_validity", ] diff --git a/src/eda_agent/design/plan.py b/src/eda_agent/design/plan.py index 2fd6a51..cda463b 100644 --- a/src/eda_agent/design/plan.py +++ b/src/eda_agent/design/plan.py @@ -21,7 +21,23 @@ _REFDES_PATTERN = r"^[A-Z]+[0-9]+[A-Z]?$" -_NET_PATTERN = r"^[A-Za-z_][A-Za-z0-9_+\-/]*$" + +#: A net name. The leading class admits + and - as well as a letter or +#: underscore, because a supply rail conventionally carries its sign: +#: +3V3, +5V, -12V. Measured on a live board, four of its seventy nets +#: were named that way and every one was refused. +#: +#: This adds no new CHARACTER to a net name. Both signs were already +#: legal in the body, so anything downstream that copes with VCC+ copes +#: with +VCC; only the position changes. +#: +#: Still deliberately narrow. The hierarchy prefix EasyEDA puts on a +#: net inside a block, "$1I81\I2C_SCL", uses $ and backslash and is NOT +#: admitted here: those are quoting and escaping characters, this +#: pattern guards what gets written into a schematic, and the prefix +#: identifies a sheet instance rather than the net. Strip it when +#: importing a live netlist into a plan. +_NET_PATTERN = r"^[A-Za-z_+\-][A-Za-z0-9_+\-/]*$" class PartStatus(str, Enum): diff --git a/src/eda_agent/design/schematic_neatness.py b/src/eda_agent/design/schematic_neatness.py index 650fc81..bb19395 100644 --- a/src/eda_agent/design/schematic_neatness.py +++ b/src/eda_agent/design/schematic_neatness.py @@ -96,7 +96,7 @@ def flags(self) -> list[str]: dimensions with a CLEAR target (not the placement-spread ones, which are board-dependent and have no single threshold). Each returned string names the dimension and its value so a caller can prioritise. An empty - list means nothing obviously wrong on the checked dimensions. + list means nothing wrong on the checked dimensions. """ out: list[tuple[int, str]] = [] # (severity, message); higher severity first. diff --git a/src/eda_agent/design/state_machine.py b/src/eda_agent/design/state_machine.py index 900302b..191792a 100644 --- a/src/eda_agent/design/state_machine.py +++ b/src/eda_agent/design/state_machine.py @@ -76,11 +76,11 @@ class NextAction: "exit_gate": "A complete DesignPlan exists with every part and net.", }, "plan_verification": { - "goal": "Vet the plan offline before any Altium round-trip.", + "goal": "Vet the plan offline, before anything reaches the editor.", "tools": ["design_validate_plan", "design_review_plan", "design_describe_circuits", "design_generate_bom"], - "exit_gate": "validate_plan ok:true; ERC-lite clean; circuit values " - "match intent.", + "exit_gate": "design_validate_plan ok:true; ERC-lite clean; circuit " + "values match intent.", }, "library_readiness": { "goal": "Ensure every part has a verified symbol + footprint (+3D).", @@ -97,10 +97,11 @@ class NextAction: "exit_gate": "ERC clean; visual-review rubric passes; no floating pins.", }, "sch_to_pcb": { - "goal": "Transfer the netlist to a PCB without the modal ECO dialog.", + "goal": "Transfer the netlist to a PCB without a dialog someone has " + "to click.", "tools": ["pcb_place_components", "pcb_build_from_project", "proj_compare_sch_pcb", "obj_crossref_net"], - "exit_gate": "compare_sch_pcb reports in_sync:true.", + "exit_gate": "proj_compare_sch_pcb reports in_sync:true.", }, "rules_stackup": { "goal": "Set the layer stack and design rules from a fab profile.", diff --git a/src/eda_agent/design/trace_sizing.py b/src/eda_agent/design/trace_sizing.py index e2f236f..ec78ec0 100644 --- a/src/eda_agent/design/trace_sizing.py +++ b/src/eda_agent/design/trace_sizing.py @@ -35,7 +35,7 @@ _K_INTERNAL = 0.024 _DT_EXP = 0.44 # temperature-rise exponent _AREA_EXP = 0.725 # cross-section exponent -_OZ_TO_MILS = 1.378 # 1 oz/ft^2 copper thickness in mils +from ..units import OZ_TO_MILS as _OZ_TO_MILS # 1 oz/ft^2 in mils _RHO_OHM_MIL = 6.7e-7 # annealed copper resistivity, ohm-mil, 25 degC diff --git a/src/eda_agent/design/visual_metrics.py b/src/eda_agent/design/visual_metrics.py index 2a6e2ed..ae1643a 100644 --- a/src/eda_agent/design/visual_metrics.py +++ b/src/eda_agent/design/visual_metrics.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba """Perceptual placement metrics: what the rendered board SHOWS, as distinct from the analytic objective the solver minimizes. diff --git a/src/eda_agent/diag/health.py b/src/eda_agent/diag/health.py index b518126..ed9e9c7 100644 --- a/src/eda_agent/diag/health.py +++ b/src/eda_agent/diag/health.py @@ -193,6 +193,52 @@ def _check_bridge_constructable() -> Check: return Check(name="bridge constructable", status=Status.PASS) +def _check_easyeda_extension_built() -> Check: + """Is the editor half of the EasyEDA bridge ready to install? + + Reported even on an Altium install, because the failure it prevents + is silent and confusing: EasyEDA DIALS OUT to this server, so a + missing extension looks exactly like a server that is not listening. + Someone debugging that will check ports and firewalls for a while + before suspecting the half that lives inside the editor. + """ + source = (Path(__file__).resolve().parents[3] + / "extensions" / "easyeda" / "main.js") + built = source.parent / "dist" / "index.js" + + if not source.is_file(): + # Not an error on a source install that omits the extension. + return Check( + name="easyeda extension", + status=Status.SKIP, + message="no extensions/easyeda/main.js in this install", + ) + + if not built.is_file(): + return Check( + name="easyeda extension", + status=Status.WARN, + severity=Severity.MINOR, + message="the extension is not built, so EasyEDA has nothing " + "to install and will never connect", + fix="Run `python extensions/easyeda/build.py`, then install " + "the folder from EasyEDA Pro: Settings > Extensions.", + ) + + if built.read_text(encoding="utf-8") != source.read_text(encoding="utf-8"): + return Check( + name="easyeda extension", + status=Status.WARN, + severity=Severity.MINOR, + message="the built extension is older than main.js, so EasyEDA " + "is running code that no longer matches this server", + fix="Rebuild with `python extensions/easyeda/build.py` and " + "reload the extension in EasyEDA.", + ) + + return Check(name="easyeda extension", status=Status.PASS) + + def run_health_checks() -> list[Check]: """Order matters, earlier failures often explain later ones.""" return [ @@ -201,4 +247,5 @@ def run_health_checks() -> list[Check]: _check_bundled_scripts(), _check_deployed_scripts_current(), _check_bridge_constructable(), + _check_easyeda_extension_built(), ] diff --git a/src/eda_agent/export/kicad_footprint.py b/src/eda_agent/export/kicad_footprint.py index 7ff8583..b1cad47 100644 --- a/src/eda_agent/export/kicad_footprint.py +++ b/src/eda_agent/export/kicad_footprint.py @@ -16,7 +16,7 @@ from typing import Any -MM_PER_MIL = 0.0254 +from eda_agent.units import MM_PER_MIL # Altium TopShape -> KiCad pad shape. Octagonal has no KiCad equal; roundrect # is the conventional substitute. diff --git a/src/eda_agent/export/stackup_csv.py b/src/eda_agent/export/stackup_csv.py index 736fc8d..5dcc7ac 100644 --- a/src/eda_agent/export/stackup_csv.py +++ b/src/eda_agent/export/stackup_csv.py @@ -16,7 +16,7 @@ from typing import Any -MM_PER_MIL = 0.0254 +from eda_agent.units import MM_PER_MIL # Conventional fab-report columns, in order. _HEADER = [ diff --git a/src/eda_agent/libimport/easyeda/kicad.py b/src/eda_agent/libimport/easyeda/kicad.py index 125b455..9b6227d 100644 --- a/src/eda_agent/libimport/easyeda/kicad.py +++ b/src/eda_agent/libimport/easyeda/kicad.py @@ -25,7 +25,7 @@ __all__ = ["footprint_to_kicad_mod", "symbol_to_kicad_sym"] -_MIL_TO_MM = 0.0254 +from eda_agent.units import MM_PER_MIL as _MIL_TO_MM #: EasyEDA electrical code -> KiCad pin electrical type. _KICAD_ELEC = { diff --git a/src/eda_agent/libimport/kicad/reader.py b/src/eda_agent/libimport/kicad/reader.py index d2ca493..17685ec 100644 --- a/src/eda_agent/libimport/kicad/reader.py +++ b/src/eda_agent/libimport/kicad/reader.py @@ -50,7 +50,7 @@ __all__ = ["read_kicad_footprint", "read_kicad_symbol", "MM_TO_MIL"] -MM_TO_MIL = 1000.0 / 25.4 +from eda_agent.units import MILS_PER_MM as MM_TO_MIL #: KiCad layer name -> EasyEDA layer id, the neutral model's vocabulary. _LAYER_TO_ID = { diff --git a/src/eda_agent/render/bom_html.py b/src/eda_agent/render/bom_html.py index 3f040d2..b55aac2 100644 --- a/src/eda_agent/render/bom_html.py +++ b/src/eda_agent/render/bom_html.py @@ -23,6 +23,41 @@ def _h(s: Any) -> str: return html.escape(str(s or ""), quote=True) +def _script_json(value: Any) -> str: + """Serialise data for embedding INSIDE a ", + "", + "", + "", + "