enginehall 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. enginehall-2.0.0.dist-info/METADATA +511 -0
  2. enginehall-2.0.0.dist-info/RECORD +75 -0
  3. enginehall-2.0.0.dist-info/WHEEL +4 -0
  4. enginehall-2.0.0.dist-info/entry_points.txt +4 -0
  5. enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
  6. pcba/THIRD_PARTY_NOTICES.md +72 -0
  7. pcba/__init__.py +6 -0
  8. pcba/adapters/__init__.py +10 -0
  9. pcba/adapters/altium_bridge/MANIFEST.json +5 -0
  10. pcba/adapters/altium_bridge/__init__.py +1 -0
  11. pcba/adapters/altium_bridge/bridge.pas +3342 -0
  12. pcba/adapters/altium_drive.py +1160 -0
  13. pcba/adapters/altium_house.py +591 -0
  14. pcba/adapters/altium_ops.py +858 -0
  15. pcba/adapters/altium_outjob.py +247 -0
  16. pcba/adapters/altium_uia.py +304 -0
  17. pcba/adapters/altium_vcs.py +197 -0
  18. pcba/adapters/altium_win32.py +457 -0
  19. pcba/adapters/cubemx_drive.py +248 -0
  20. pcba/baseline.py +151 -0
  21. pcba/changes.py +81 -0
  22. pcba/cli.py +891 -0
  23. pcba/components.py +323 -0
  24. pcba/config.py +180 -0
  25. pcba/data/components/generic.json +61 -0
  26. pcba/discover.py +350 -0
  27. pcba/doctor.py +131 -0
  28. pcba/house.py +123 -0
  29. pcba/mcp/__init__.py +5 -0
  30. pcba/mcp/altium_server.py +1358 -0
  31. pcba/mcp/protocol.py +224 -0
  32. pcba/mcp/server.py +332 -0
  33. pcba/model/__init__.py +38 -0
  34. pcba/model/design.py +232 -0
  35. pcba/model/finding.py +148 -0
  36. pcba/model/pinmap.py +200 -0
  37. pcba/parsers/__init__.py +0 -0
  38. pcba/parsers/altium/__init__.py +37 -0
  39. pcba/parsers/altium/_sheet.py +293 -0
  40. pcba/parsers/altium/geometry.py +103 -0
  41. pcba/parsers/altium/netlist.py +302 -0
  42. pcba/parsers/altium/pinmap.py +336 -0
  43. pcba/parsers/altium/project.py +249 -0
  44. pcba/parsers/altium/records.py +227 -0
  45. pcba/parsers/firmware/__init__.py +5 -0
  46. pcba/parsers/firmware/index.py +250 -0
  47. pcba/parsers/ioc/__init__.py +49 -0
  48. pcba/parsers/ioc/mcu_db.py +180 -0
  49. pcba/parsers/ioc/reader.py +182 -0
  50. pcba/parsers/ioc/validate.py +192 -0
  51. pcba/parsers/kicad/__init__.py +103 -0
  52. pcba/parsers/kicad/board.py +269 -0
  53. pcba/parsers/kicad/kicad_cli.py +190 -0
  54. pcba/parsers/kicad/pinmap.py +90 -0
  55. pcba/parsers/kicad/schematic.py +517 -0
  56. pcba/parsers/kicad/sexpr.py +136 -0
  57. pcba/publish.py +269 -0
  58. pcba/report/__init__.py +7 -0
  59. pcba/report/json_report.py +39 -0
  60. pcba/report/sarif.py +128 -0
  61. pcba/report/text.py +304 -0
  62. pcba/requirements.py +320 -0
  63. pcba/review.py +177 -0
  64. pcba/rules/__init__.py +312 -0
  65. pcba/rules/cross_domain.py +496 -0
  66. pcba/rules/kicad_board.py +775 -0
  67. pcba/rules/kicad_common.py +167 -0
  68. pcba/rules/kicad_components.py +603 -0
  69. pcba/rules/kicad_schematic.py +621 -0
  70. pcba/rules/locate.py +22 -0
  71. pcba/rules/schematic.py +123 -0
  72. pcba/rules/signals.py +149 -0
  73. pcba/rules/spice.py +59 -0
  74. pcba/rules/stm32.py +155 -0
  75. pcba/simulation.py +143 -0
@@ -0,0 +1,190 @@
1
+ """Run KiCad's own checks through `kicad-cli`, when it is installed.
2
+
3
+ `kicad-cli` is optional. Without it, ERC, DRC and the netlist cross-check are reported as not
4
+ checked, never as passed. With it, the review runs:
5
+
6
+ - `sch erc --format json --severity-all`: KiCad's electrical rules check,
7
+ - `pcb drc --format json --severity-all --schematic-parity`: KiCad's design rules check plus
8
+ the schematic-to-board parity check,
9
+ - `sch export netlist --format kicadsexpr`: KiCad's netlist, compared with the engine's own
10
+ connectivity so that a disagreement shows up as a finding instead of a silent wrong answer.
11
+
12
+ kicad-cli writes `<project>.kicad_prl` (local settings) next to the project file it opens. A
13
+ review must not change the project, so kicad-cli never sees the project folder itself: it runs on
14
+ a temporary mirror in which the KiCad files (`.kicad_pro`, `.kicad_prl`, `.kicad_sch`, `.kicad_pcb`,
15
+ `.kicad_dru`, the library tables) are copies and every other entry is a link, so project-relative
16
+ libraries and models still resolve. Libraries referenced outside the project folder may not, which
17
+ shows up as KiCad's library-configuration messages, reported as information.
18
+
19
+ The `PCBA_KICAD_CLI` environment variable names the executable, or switches the tool off with
20
+ `off` (the test suite does that, so its exact counts do not depend on the machine).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import glob
26
+ import json
27
+ import os
28
+ import shutil
29
+ import subprocess
30
+ import sys
31
+ import tempfile
32
+ from pathlib import Path
33
+
34
+ from pcba.parsers.kicad.sexpr import SexprError, load
35
+
36
+ ENVIRONMENT = "PCBA_KICAD_CLI"
37
+ TIMEOUT_S = 300
38
+ _OFF = {"off", "0", "none", "no", "false", ""}
39
+
40
+
41
+ def candidates() -> list[str]:
42
+ """Where KiCad installs its command-line tool on each OS."""
43
+ home = Path.home()
44
+ out = [
45
+ "/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
46
+ str(home / "Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli"),
47
+ "/usr/bin/kicad-cli",
48
+ "/usr/local/bin/kicad-cli",
49
+ ]
50
+ if sys.platform == "win32":
51
+ for base in (os.environ.get("ProgramFiles", r"C:\Program Files"), os.environ.get("LOCALAPPDATA", "")):
52
+ if base:
53
+ out += sorted(glob.glob(os.path.join(base, "KiCad", "*", "bin", "kicad-cli.exe")), reverse=True)
54
+ out += sorted(glob.glob(os.path.join(base, "Programs", "KiCad", "*", "bin", "kicad-cli.exe")),
55
+ reverse=True)
56
+ return out
57
+
58
+
59
+ def find() -> tuple[Path | None, str]:
60
+ """(executable, how it was found or why not)."""
61
+ configured = os.environ.get(ENVIRONMENT)
62
+ if configured is not None:
63
+ if configured.strip().lower() in _OFF:
64
+ return None, f"switched off by {ENVIRONMENT}"
65
+ path = Path(configured).expanduser()
66
+ if path.is_file():
67
+ return path, f"{ENVIRONMENT}"
68
+ return None, f"{ENVIRONMENT} names {configured}, which does not exist"
69
+ found = shutil.which("kicad-cli")
70
+ if found:
71
+ return Path(found), "PATH"
72
+ for candidate in candidates():
73
+ if Path(candidate).is_file():
74
+ return Path(candidate), "default install location"
75
+ return None, "kicad-cli not found (install KiCad, or set PCBA_KICAD_CLI to its path)"
76
+
77
+
78
+ def _run(arguments: list[str], cwd: Path) -> subprocess.CompletedProcess:
79
+ return subprocess.run(arguments, cwd=cwd, capture_output=True, text=True, timeout=TIMEOUT_S,
80
+ errors="replace", check=False)
81
+
82
+
83
+ def version(cli: Path) -> str | None:
84
+ try:
85
+ done = _run([str(cli), "version"], Path(tempfile.gettempdir()))
86
+ except (OSError, subprocess.TimeoutExpired):
87
+ return None
88
+ text = done.stdout.strip().splitlines()
89
+ return text[-1] if text else None
90
+
91
+
92
+ def _report(cli: Path, arguments: list[str], suffix: str) -> dict:
93
+ """Run one kicad-cli report into a temporary file; {"ran": bool, "reason"|"report"}."""
94
+ with tempfile.TemporaryDirectory(prefix="pcba-kicad-") as tmp:
95
+ out = Path(tmp) / f"report{suffix}"
96
+ try:
97
+ done = _run([str(cli), *arguments, "-o", str(out)], Path(tmp))
98
+ except subprocess.TimeoutExpired:
99
+ return {"ran": False, "reason": f"kicad-cli timed out after {TIMEOUT_S} s"}
100
+ except OSError as exc:
101
+ return {"ran": False, "reason": f"kicad-cli could not start: {exc}"}
102
+ if not out.is_file():
103
+ detail = (done.stderr or done.stdout).strip().splitlines()
104
+ useful = [line for line in detail if "Fontconfig" not in line]
105
+ return {"ran": False, "reason": f"kicad-cli exited {done.returncode} without a report"
106
+ + (f": {useful[-1]}" if useful else "")}
107
+ if suffix == ".json":
108
+ try:
109
+ return {"ran": True, "report": json.loads(out.read_text(encoding="utf-8"))}
110
+ except (OSError, ValueError) as exc:
111
+ return {"ran": False, "reason": f"kicad-cli wrote an unreadable report: {exc}"}
112
+ try:
113
+ return {"ran": True, "report": netlist_nodes(out)}
114
+ except (OSError, SexprError) as exc:
115
+ return {"ran": False, "reason": f"kicad-cli wrote an unreadable netlist: {exc}"}
116
+
117
+
118
+ def netlist_nodes(path: Path) -> dict[str, list[list[str]]]:
119
+ """A `kicadsexpr` netlist as {net name: [[ref, pin], ...]}."""
120
+ root = load(path)
121
+ out: dict[str, list[list[str]]] = {}
122
+ nets = root.child("nets")
123
+ for net in nets.children("net") if nets is not None else ():
124
+ name = net.value("name") or ""
125
+ out[name] = [[node.value("ref") or "", node.value("pin") or ""] for node in net.children("node")]
126
+ return out
127
+
128
+
129
+ COPIED_SUFFIXES = (".kicad_pro", ".kicad_prl", ".kicad_sch", ".kicad_pcb", ".kicad_dru")
130
+ COPIED_NAMES = ("sym-lib-table", "fp-lib-table")
131
+ # Linked without looking inside: searching them for sheets would crawl a whole repository.
132
+ LINKED_DIRECTORIES = frozenset({"node_modules", "build", "dist", "venv", "__pycache__"})
133
+
134
+
135
+ def mirror(folder: Path, target: Path) -> None:
136
+ """Copy the KiCad files of `folder` into `target` and link everything else."""
137
+ for entry in sorted(folder.iterdir()):
138
+ destination = target / entry.name
139
+ if entry.is_file() and (entry.suffix in COPIED_SUFFIXES or entry.name in COPIED_NAMES):
140
+ shutil.copy2(entry, destination)
141
+ elif entry.is_dir() and entry.name not in LINKED_DIRECTORIES and not entry.name.startswith(".") \
142
+ and any(entry.rglob("*.kicad_sch")):
143
+ # Sheets in a subfolder: copy the subfolder's KiCad files the same way.
144
+ destination.mkdir()
145
+ mirror(entry, destination)
146
+ else:
147
+ try:
148
+ destination.symlink_to(entry.resolve(), target_is_directory=entry.is_dir())
149
+ except OSError:
150
+ continue # no link permission (Windows): models and libraries may then be missing
151
+
152
+
153
+ def run_all(schematic: Path | None, board: Path | None) -> dict[str, dict]:
154
+ """ERC, netlist and DRC for one project, each {"ran": ..., "tool": ..., ...}."""
155
+ cli, how = find()
156
+ results: dict[str, dict] = {}
157
+ if cli is None:
158
+ for name in ("erc", "netlist", "drc"):
159
+ results[name] = {"ran": False, "reason": how}
160
+ return results
161
+ anchor = schematic or board
162
+ with tempfile.TemporaryDirectory(prefix="pcba-kicad-project-") as tmp:
163
+ try:
164
+ mirror(anchor.parent, Path(tmp))
165
+ except OSError as exc:
166
+ for name in ("erc", "netlist", "drc"):
167
+ results[name] = {"ran": False, "reason": f"cannot mirror the project for kicad-cli: {exc}"}
168
+ return results
169
+ return _run_all(cli, how, Path(tmp) / schematic.name if schematic else None,
170
+ Path(tmp) / board.relative_to(anchor.parent) if board else None)
171
+
172
+
173
+ def _run_all(cli: Path, how: str, schematic: Path | None, board: Path | None) -> dict[str, dict]:
174
+ results: dict[str, dict] = {}
175
+ tool = {"tool": "kicad-cli", "path": str(cli), "found_by": how, "version": version(cli)}
176
+ if schematic is not None:
177
+ results["erc"] = {**tool, **_report(cli, ["sch", "erc", "--format", "json", "--severity-all",
178
+ str(schematic)], ".json")}
179
+ results["netlist"] = {**tool, **_report(cli, ["sch", "export", "netlist", "--format", "kicadsexpr",
180
+ str(schematic)], ".net")}
181
+ else:
182
+ results["erc"] = results["netlist"] = {"ran": False, "reason": "no schematic"}
183
+ if board is not None:
184
+ arguments = ["pcb", "drc", "--format", "json", "--severity-all"]
185
+ if schematic is not None:
186
+ arguments.append("--schematic-parity")
187
+ results["drc"] = {**tool, **_report(cli, [*arguments, str(board)], ".json")}
188
+ else:
189
+ results["drc"] = {"ran": False, "reason": "no board"}
190
+ return results
@@ -0,0 +1,90 @@
1
+ """Map a KiCad schematic onto the MCU pin map the STM32 cross-domain rules read.
2
+
3
+ The rules compare one MCU's pins with the `.ioc` and the firmware. For a KiCad project the pins
4
+ come from the connectivity the reader built from wire geometry; the net name a pin carries is
5
+ the label a designer gave its net (without the sheet path), so `/Sensors/I2C1_SDA` compares as
6
+ `I2C1_SDA`, exactly as an Altium net label would. An unnamed net reached through one resistor
7
+ takes the name on the far side, marked as a series-resistor inference; several names there are
8
+ ambiguous; none leaves the pin unnamed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ from pcba.model.design import Component, Schematic
16
+ from pcba.model.pinmap import Pin, Resolution, SchematicPinMap
17
+ from pcba.parsers.kicad.schematic import KicadError
18
+
19
+
20
+ def _part_name(component: Component, hint: str) -> str | None:
21
+ for candidate in (component.value, component.lib_id.split(":")[-1]):
22
+ if candidate.upper().startswith(hint.upper()) and len(candidate) > len(hint):
23
+ return candidate
24
+ return None
25
+
26
+
27
+ def detect_parts(schematic: Schematic, hint: str = "STM32") -> list[tuple[str, str]]:
28
+ """Distinct MCU part names and the sheet each is first drawn on."""
29
+ found: dict[str, str] = {}
30
+ for component in schematic.components.values():
31
+ name = _part_name(component, hint)
32
+ if name and not component.power:
33
+ found.setdefault(name, component.sheet_file)
34
+ return sorted(found.items())
35
+
36
+
37
+ def detect_part(schematic: Schematic, hint: str = "STM32") -> str | None:
38
+ parts = detect_parts(schematic, hint)
39
+ return parts[0][0] if parts else None
40
+
41
+
42
+ def _is_resistor(component: Component) -> bool:
43
+ lib = component.lib_id.split(":")[-1]
44
+ return (len({p.number for p in component.pins}) == 2
45
+ and (re.fullmatch(r"R(_.*)?|R_Small.*|Resistor.*", lib, re.I) is not None
46
+ or re.fullmatch(r"R\d+", component.ref) is not None))
47
+
48
+
49
+ def extract(schematic: Schematic, part: str, component: str | None = None) -> SchematicPinMap:
50
+ family = re.match(r"^([A-Za-z]+\d*)", part or "")
51
+ if not family:
52
+ raise KicadError(f"cannot derive an MCU family from part {part!r}; pass --part")
53
+ hint = family.group(1)
54
+ candidates = [c for c in schematic.components.values()
55
+ if not c.power and _part_name(c, hint) and (component is None or c.ref == component)]
56
+ exact = [c for c in candidates if _part_name(c, hint).upper() == part.upper()]
57
+ chosen = exact or candidates
58
+ if not chosen:
59
+ raise KicadError(f"no symbol of {part} in the KiCad schematic"
60
+ + ("; check --schematic-component" if component else ""))
61
+ refs = sorted({c.ref for c in chosen})
62
+ if len(refs) > 1:
63
+ raise KicadError("several MCU instances found (" + ", ".join(refs)
64
+ + "); select one with --schematic-component")
65
+ mcu = chosen[0]
66
+ pin_map = SchematicPinMap(part=part, source=schematic.root_file)
67
+ for pin in sorted(mcu.pins, key=lambda p: p.number):
68
+ net = schematic.net_of(mcu.ref, pin.number)
69
+ name, how = (net.label, Resolution.DIRECT) if net and net.label else (None, Resolution.NONE)
70
+ if net and not name:
71
+ reachable = []
72
+ for other in net.parts:
73
+ owner = schematic.components.get(other.ref)
74
+ if owner is None or other.ref == mcu.ref or not _is_resistor(owner):
75
+ continue
76
+ for far in owner.pins:
77
+ far_net = schematic.net_of(owner.ref, far.number)
78
+ if far.number != other.number and far_net and far_net.label:
79
+ reachable.append(far_net.label)
80
+ reachable = sorted(set(reachable))
81
+ if len(reachable) == 1:
82
+ name, how = reachable[0], Resolution.SERIES_R
83
+ elif reachable:
84
+ name, how = "(" + " / ".join(reachable) + ")", Resolution.AMBIGUOUS
85
+ connected = bool(net and (len(net.pins) > 1 or net.labels))
86
+ pin_map.pins[pin.number] = Pin(
87
+ name=pin.name or pin.number, package_pin=pin.number, net=name, resolution=how,
88
+ sheet=pin.sheet_file, component=mcu.ref, connected=connected,
89
+ )
90
+ return pin_map