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.
- enginehall-2.0.0.dist-info/METADATA +511 -0
- enginehall-2.0.0.dist-info/RECORD +75 -0
- enginehall-2.0.0.dist-info/WHEEL +4 -0
- enginehall-2.0.0.dist-info/entry_points.txt +4 -0
- enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
- pcba/THIRD_PARTY_NOTICES.md +72 -0
- pcba/__init__.py +6 -0
- pcba/adapters/__init__.py +10 -0
- pcba/adapters/altium_bridge/MANIFEST.json +5 -0
- pcba/adapters/altium_bridge/__init__.py +1 -0
- pcba/adapters/altium_bridge/bridge.pas +3342 -0
- pcba/adapters/altium_drive.py +1160 -0
- pcba/adapters/altium_house.py +591 -0
- pcba/adapters/altium_ops.py +858 -0
- pcba/adapters/altium_outjob.py +247 -0
- pcba/adapters/altium_uia.py +304 -0
- pcba/adapters/altium_vcs.py +197 -0
- pcba/adapters/altium_win32.py +457 -0
- pcba/adapters/cubemx_drive.py +248 -0
- pcba/baseline.py +151 -0
- pcba/changes.py +81 -0
- pcba/cli.py +891 -0
- pcba/components.py +323 -0
- pcba/config.py +180 -0
- pcba/data/components/generic.json +61 -0
- pcba/discover.py +350 -0
- pcba/doctor.py +131 -0
- pcba/house.py +123 -0
- pcba/mcp/__init__.py +5 -0
- pcba/mcp/altium_server.py +1358 -0
- pcba/mcp/protocol.py +224 -0
- pcba/mcp/server.py +332 -0
- pcba/model/__init__.py +38 -0
- pcba/model/design.py +232 -0
- pcba/model/finding.py +148 -0
- pcba/model/pinmap.py +200 -0
- pcba/parsers/__init__.py +0 -0
- pcba/parsers/altium/__init__.py +37 -0
- pcba/parsers/altium/_sheet.py +293 -0
- pcba/parsers/altium/geometry.py +103 -0
- pcba/parsers/altium/netlist.py +302 -0
- pcba/parsers/altium/pinmap.py +336 -0
- pcba/parsers/altium/project.py +249 -0
- pcba/parsers/altium/records.py +227 -0
- pcba/parsers/firmware/__init__.py +5 -0
- pcba/parsers/firmware/index.py +250 -0
- pcba/parsers/ioc/__init__.py +49 -0
- pcba/parsers/ioc/mcu_db.py +180 -0
- pcba/parsers/ioc/reader.py +182 -0
- pcba/parsers/ioc/validate.py +192 -0
- pcba/parsers/kicad/__init__.py +103 -0
- pcba/parsers/kicad/board.py +269 -0
- pcba/parsers/kicad/kicad_cli.py +190 -0
- pcba/parsers/kicad/pinmap.py +90 -0
- pcba/parsers/kicad/schematic.py +517 -0
- pcba/parsers/kicad/sexpr.py +136 -0
- pcba/publish.py +269 -0
- pcba/report/__init__.py +7 -0
- pcba/report/json_report.py +39 -0
- pcba/report/sarif.py +128 -0
- pcba/report/text.py +304 -0
- pcba/requirements.py +320 -0
- pcba/review.py +177 -0
- pcba/rules/__init__.py +312 -0
- pcba/rules/cross_domain.py +496 -0
- pcba/rules/kicad_board.py +775 -0
- pcba/rules/kicad_common.py +167 -0
- pcba/rules/kicad_components.py +603 -0
- pcba/rules/kicad_schematic.py +621 -0
- pcba/rules/locate.py +22 -0
- pcba/rules/schematic.py +123 -0
- pcba/rules/signals.py +149 -0
- pcba/rules/spice.py +59 -0
- pcba/rules/stm32.py +155 -0
- 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
|