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,517 @@
|
|
|
1
|
+
"""Read a KiCad schematic hierarchy and build its nets from geometry.
|
|
2
|
+
|
|
3
|
+
What KiCad considers connected, measured against `kicad-cli sch export netlist` (KiCad 10.0.7)
|
|
4
|
+
on synthetic sheets, and what this reader therefore does:
|
|
5
|
+
|
|
6
|
+
- Items whose points coincide are connected: pin ends, wire ends, labels, junctions, sheet pins.
|
|
7
|
+
- A **label** (local, global or hierarchical), a **junction** or a **sheet pin** joins a wire
|
|
8
|
+
anywhere along the wire, not only at its ends.
|
|
9
|
+
- A **pin end** (power-symbol pins included) or a **wire end** lying in the interior of another
|
|
10
|
+
wire does **not** join it. A T drawn without a junction is two nets; so are two collinear wires
|
|
11
|
+
that overlap. KiCad's editor adds the junction when a person draws the T, which is why
|
|
12
|
+
generated files are where this bites.
|
|
13
|
+
- Two wires that merely cross do not connect; a junction on the crossing does.
|
|
14
|
+
- A no-connect flag never connects anything. On a pin end it marks the pin as intentionally
|
|
15
|
+
open.
|
|
16
|
+
- Local labels and hierarchical labels with the same text join within one sheet instance.
|
|
17
|
+
Global labels and global power symbols join across the whole project; a KiCad 9+ local power
|
|
18
|
+
symbol joins only within its sheet. A hierarchical label joins the same-named pin of its sheet
|
|
19
|
+
symbol in the parent.
|
|
20
|
+
- A power symbol names its net with its **Value** field. A hidden power-input pin on an
|
|
21
|
+
ordinary symbol (older libraries) joins the global net named after the pin.
|
|
22
|
+
- A placed symbol's pin at (px, py) in symbol space (y up) is rotated counter-clockwise by the
|
|
23
|
+
symbol angle first and mirrored after (`mirror x` negates the rotated y, `mirror y` the
|
|
24
|
+
rotated x), then placed at (X + px', Y - py'), schematic space being y down. The order
|
|
25
|
+
matters only at 90 and 270 degrees, and it was measured against `kicad-cli` netlists: the
|
|
26
|
+
other order swaps the pins of every rotated, mirrored symbol.
|
|
27
|
+
- A sheet file used twice is two instances with separate nets and its own references, taken
|
|
28
|
+
from the symbol's `(instances (project ... (path ...)))` entry for that instance.
|
|
29
|
+
|
|
30
|
+
Buses are not resolved: a design that carries signals through bus entries is reported with a
|
|
31
|
+
note, and the kicad-cli netlist cross-check shows which nets that affects.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import dataclasses
|
|
37
|
+
import json
|
|
38
|
+
import math
|
|
39
|
+
import re
|
|
40
|
+
from collections import defaultdict
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
from pcba.model.design import Component, Net, SchLabel, Schematic, SchPin
|
|
44
|
+
from pcba.parsers.altium.geometry import PointIndex, UnionFind
|
|
45
|
+
from pcba.parsers.kicad.sexpr import Node, SexprError, load
|
|
46
|
+
|
|
47
|
+
# KiCad stores schematic coordinates in mm with at most four decimals (100 nm).
|
|
48
|
+
ON_WIRE_MM = 0.001
|
|
49
|
+
# Item kinds that join a wire anywhere along it: labels, junctions, sheet pins.
|
|
50
|
+
INTERIOR_JOINS = frozenset({"l", "j", "s"})
|
|
51
|
+
_UNIT = re.compile(r"^(.*)_(\d+)_(\d+)$")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class KicadError(RuntimeError):
|
|
55
|
+
"""A KiCad file is missing or cannot be read as KiCad."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _key(x: float, y: float) -> tuple[float, float]:
|
|
59
|
+
return (round(x, 4) + 0.0, round(y, 4) + 0.0)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
63
|
+
class LibPin:
|
|
64
|
+
number: str
|
|
65
|
+
name: str
|
|
66
|
+
type: str
|
|
67
|
+
x: float
|
|
68
|
+
y: float
|
|
69
|
+
hidden: bool
|
|
70
|
+
unit: int
|
|
71
|
+
style: int
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclasses.dataclass(slots=True)
|
|
75
|
+
class LibSymbol:
|
|
76
|
+
name: str
|
|
77
|
+
power: str | None # "global", "local" or None
|
|
78
|
+
pins: list[LibPin]
|
|
79
|
+
extends: str | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def lib_symbols(root: Node) -> dict[str, LibSymbol]:
|
|
83
|
+
"""The embedded symbol library of one schematic file, by its full name ("Device:R")."""
|
|
84
|
+
out: dict[str, LibSymbol] = {}
|
|
85
|
+
library = root.child("lib_symbols")
|
|
86
|
+
if library is None:
|
|
87
|
+
return out
|
|
88
|
+
for symbol in library.children("symbol"):
|
|
89
|
+
full = symbol[1] if len(symbol) > 1 and isinstance(symbol[1], str) else ""
|
|
90
|
+
power_node = symbol.child("power")
|
|
91
|
+
power = None
|
|
92
|
+
if power_node is not None:
|
|
93
|
+
atoms = power_node.atoms()
|
|
94
|
+
power = atoms[0] if atoms else "global"
|
|
95
|
+
pins: list[LibPin] = []
|
|
96
|
+
units = list(symbol.children("symbol")) or [symbol]
|
|
97
|
+
for unit_symbol in units:
|
|
98
|
+
unit, style = 0, 0
|
|
99
|
+
match = _UNIT.match(unit_symbol[1] if len(unit_symbol) > 1 else "")
|
|
100
|
+
if match and unit_symbol is not symbol:
|
|
101
|
+
unit, style = int(match.group(2)), int(match.group(3))
|
|
102
|
+
for pin in unit_symbol.children("pin"):
|
|
103
|
+
at = pin.xy("at") or (0.0, 0.0, 0.0)
|
|
104
|
+
atoms = pin.atoms()
|
|
105
|
+
name = pin.value("name", "") or ""
|
|
106
|
+
pins.append(LibPin(
|
|
107
|
+
number=pin.value("number", "") or "",
|
|
108
|
+
name="" if name == "~" else name,
|
|
109
|
+
type=atoms[0] if atoms else "unspecified",
|
|
110
|
+
x=at[0], y=at[1], hidden=pin.flag("hide"), unit=unit, style=style,
|
|
111
|
+
))
|
|
112
|
+
out[full] = LibSymbol(full, power, pins, symbol.value("extends"))
|
|
113
|
+
# A derived symbol (`extends`) keeps its parent's pins.
|
|
114
|
+
for name, symbol in out.items():
|
|
115
|
+
if symbol.extends and not symbol.pins:
|
|
116
|
+
library_prefix = name.split(":")[0] + ":" if ":" in name else ""
|
|
117
|
+
parent = out.get(library_prefix + symbol.extends) or out.get(symbol.extends)
|
|
118
|
+
if parent:
|
|
119
|
+
symbol.pins = list(parent.pins)
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def place(px: float, py: float, x: float, y: float, angle: float, mirror: str | None) -> tuple[float, float]:
|
|
124
|
+
"""Absolute schematic position of a symbol-space point (see the module docstring)."""
|
|
125
|
+
rad = math.radians(angle)
|
|
126
|
+
cos, sin = round(math.cos(rad), 12), round(math.sin(rad), 12)
|
|
127
|
+
rx, ry = px * cos - py * sin, px * sin + py * cos
|
|
128
|
+
if mirror == "x":
|
|
129
|
+
ry = -ry
|
|
130
|
+
elif mirror == "y":
|
|
131
|
+
rx = -rx
|
|
132
|
+
return x + rx, y - ry
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _property(node: Node, name: str) -> str | None:
|
|
136
|
+
for prop in node.children("property"):
|
|
137
|
+
atoms = prop.atoms()
|
|
138
|
+
if atoms and atoms[0] == name:
|
|
139
|
+
return atoms[1] if len(atoms) > 1 else ""
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _properties(node: Node) -> dict[str, str]:
|
|
144
|
+
out = {}
|
|
145
|
+
for prop in node.children("property"):
|
|
146
|
+
atoms = prop.atoms()
|
|
147
|
+
if len(atoms) >= 2:
|
|
148
|
+
out[atoms[0]] = atoms[1]
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _instance_reference(symbol: Node, instance_path: str, legacy: dict[str, tuple[str, int]]) -> tuple[str, int]:
|
|
153
|
+
"""Reference and unit of a placed symbol in one sheet instance."""
|
|
154
|
+
unit = int(float(symbol.value("unit", "1") or 1))
|
|
155
|
+
instances = symbol.child("instances")
|
|
156
|
+
if instances is not None:
|
|
157
|
+
for project in instances.children("project"):
|
|
158
|
+
for path in project.children("path"):
|
|
159
|
+
atoms = path.atoms()
|
|
160
|
+
if atoms and atoms[0] == instance_path:
|
|
161
|
+
return path.value("reference", "") or "", int(float(path.value("unit", str(unit)) or unit))
|
|
162
|
+
uuid = symbol.value("uuid")
|
|
163
|
+
if uuid:
|
|
164
|
+
# KiCad 6 kept the references in the root's `symbol_instances`, keyed by the sheet path
|
|
165
|
+
# without the root uuid.
|
|
166
|
+
tail = "/".join(instance_path.split("/")[2:])
|
|
167
|
+
hit = legacy.get(("/" + tail + "/" if tail else "/") + uuid)
|
|
168
|
+
if hit:
|
|
169
|
+
return hit
|
|
170
|
+
return _property(symbol, "Reference") or "?", unit
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclasses.dataclass(slots=True)
|
|
174
|
+
class _Instance:
|
|
175
|
+
path: str # uuid path, "/<root uuid>/<sheet uuid>"
|
|
176
|
+
names: str # sheet path, "/" or "/Power/"
|
|
177
|
+
file: Path
|
|
178
|
+
node: Node
|
|
179
|
+
parent: str | None = None
|
|
180
|
+
sheet_pins: dict[str, list[tuple[str, tuple[float, float], int]]] = dataclasses.field(default_factory=dict)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _sheet_file_property(sheet: Node) -> tuple[str, str]:
|
|
184
|
+
name = _property(sheet, "Sheetname") or _property(sheet, "Sheet name") or ""
|
|
185
|
+
file = _property(sheet, "Sheetfile") or _property(sheet, "Sheet file") or ""
|
|
186
|
+
return name, file
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _instances(root_file: Path, root: Node, cache: dict[Path, Node], notes: list[str]) -> list[_Instance]:
|
|
190
|
+
root_uuid = root.value("uuid", "") or ""
|
|
191
|
+
out = [_Instance(path="/" + root_uuid, names="/", file=root_file, node=root)]
|
|
192
|
+
stack = [(out[0], (root_file.resolve(),))]
|
|
193
|
+
while stack:
|
|
194
|
+
parent, chain = stack.pop()
|
|
195
|
+
for sheet in parent.node.children("sheet"):
|
|
196
|
+
name, file = _sheet_file_property(sheet)
|
|
197
|
+
child_file = (parent.file.parent / file).resolve()
|
|
198
|
+
uuid = sheet.value("uuid", "") or ""
|
|
199
|
+
child_path = f"{parent.path}/{uuid}"
|
|
200
|
+
if child_file in chain:
|
|
201
|
+
notes.append(f"sheet {name or file} includes itself; the recursion is cut there")
|
|
202
|
+
continue
|
|
203
|
+
if not child_file.is_file():
|
|
204
|
+
notes.append(f"sheet file {file} of sheet {name or uuid} not found; its nets are missing")
|
|
205
|
+
continue
|
|
206
|
+
if child_file not in cache:
|
|
207
|
+
try:
|
|
208
|
+
cache[child_file] = load(child_file)
|
|
209
|
+
except (OSError, SexprError) as exc:
|
|
210
|
+
notes.append(f"cannot read sheet {file}: {exc}")
|
|
211
|
+
continue
|
|
212
|
+
child = _Instance(path=child_path, names=f"{parent.names}{name}/", file=child_file,
|
|
213
|
+
node=cache[child_file], parent=parent.path)
|
|
214
|
+
pins = parent.sheet_pins.setdefault(child_path, [])
|
|
215
|
+
for pin in sheet.children("pin"):
|
|
216
|
+
at = pin.xy("at") or (0.0, 0.0, 0.0)
|
|
217
|
+
pins.append((pin[1] if len(pin) > 1 else "", (at[0], at[1]), pin.line))
|
|
218
|
+
out.append(child)
|
|
219
|
+
stack.append((child, chain + (child_file,)))
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def find_root(directory: Path) -> tuple[Path | None, Path | None]:
|
|
224
|
+
"""(project file, root schematic) of the KiCad project in `directory`, if any."""
|
|
225
|
+
projects = sorted(directory.glob("*.kicad_pro"))
|
|
226
|
+
for project in projects:
|
|
227
|
+
root = project.with_suffix(".kicad_sch")
|
|
228
|
+
if root.is_file():
|
|
229
|
+
return project, root
|
|
230
|
+
sheets = sorted(directory.glob("*.kicad_sch"))
|
|
231
|
+
if not sheets:
|
|
232
|
+
return (projects[0] if projects else None), None
|
|
233
|
+
# Without a project file, the root is a sheet no other sheet includes.
|
|
234
|
+
included: set[str] = set()
|
|
235
|
+
for sheet in sheets:
|
|
236
|
+
try:
|
|
237
|
+
text = sheet.read_text(encoding="utf-8", errors="replace")
|
|
238
|
+
except OSError:
|
|
239
|
+
continue
|
|
240
|
+
included.update(re.findall(r'\(property\s+"Sheet ?file"\s+"([^"]+)"', text))
|
|
241
|
+
roots = [s for s in sheets if s.name not in included]
|
|
242
|
+
return None, (roots or sheets)[0]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def read(path: Path | str) -> Schematic:
|
|
246
|
+
"""Read a project (`.kicad_pro`), a root `.kicad_sch` or a directory holding one."""
|
|
247
|
+
path = Path(path)
|
|
248
|
+
project_file: Path | None = None
|
|
249
|
+
if path.is_dir():
|
|
250
|
+
project_file, root_file = find_root(path)
|
|
251
|
+
if root_file is None:
|
|
252
|
+
raise KicadError(f"no KiCad schematic in {path}")
|
|
253
|
+
elif path.suffix == ".kicad_pro":
|
|
254
|
+
project_file, root_file = path, path.with_suffix(".kicad_sch")
|
|
255
|
+
else:
|
|
256
|
+
root_file = path
|
|
257
|
+
candidate = path.with_suffix(".kicad_pro")
|
|
258
|
+
project_file = candidate if candidate.is_file() else None
|
|
259
|
+
if not root_file.is_file():
|
|
260
|
+
raise KicadError(f"root schematic {root_file} not found")
|
|
261
|
+
if project_file is not None:
|
|
262
|
+
try:
|
|
263
|
+
json.loads(project_file.read_text(encoding="utf-8"))
|
|
264
|
+
except (OSError, ValueError) as exc:
|
|
265
|
+
raise KicadError(f"cannot read {project_file.name}: {exc}") from exc
|
|
266
|
+
try:
|
|
267
|
+
root = load(root_file)
|
|
268
|
+
except SexprError as exc:
|
|
269
|
+
raise KicadError(f"cannot read {root_file.name}: {exc}") from exc
|
|
270
|
+
if root.head != "kicad_sch":
|
|
271
|
+
raise KicadError(f"{root_file.name} is not a KiCad schematic")
|
|
272
|
+
return _build(project_file, root_file.resolve(), root)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _legacy_instances(root: Node) -> dict[str, tuple[str, int]]:
|
|
276
|
+
out: dict[str, tuple[str, int]] = {}
|
|
277
|
+
section = root.child("symbol_instances")
|
|
278
|
+
if section is not None:
|
|
279
|
+
for path in section.children("path"):
|
|
280
|
+
atoms = path.atoms()
|
|
281
|
+
if atoms:
|
|
282
|
+
out[atoms[0]] = (path.value("reference", "") or "",
|
|
283
|
+
int(float(path.value("unit", "1") or 1)))
|
|
284
|
+
return out
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _build(project_file: Path | None, root_file: Path, root: Node) -> Schematic:
|
|
288
|
+
notes: list[str] = []
|
|
289
|
+
cache: dict[Path, Node] = {root_file: root}
|
|
290
|
+
instances = _instances(root_file, root, cache, notes)
|
|
291
|
+
legacy = _legacy_instances(root)
|
|
292
|
+
schematic = Schematic(
|
|
293
|
+
project_file=str(project_file.resolve()) if project_file else None,
|
|
294
|
+
root_file=str(root_file),
|
|
295
|
+
sheet_files=sorted({str(i.file) for i in instances}),
|
|
296
|
+
sheet_paths={i.names: str(i.file) for i in instances},
|
|
297
|
+
)
|
|
298
|
+
libraries = {file: lib_symbols(node) for file, node in cache.items()}
|
|
299
|
+
|
|
300
|
+
graph = UnionFind()
|
|
301
|
+
cluster_pins: dict[object, list[SchPin]] = defaultdict(list)
|
|
302
|
+
cluster_labels: dict[object, list[tuple[SchLabel, int]]] = defaultdict(list)
|
|
303
|
+
cluster_nc: set[object] = set()
|
|
304
|
+
components: dict[str, Component] = {}
|
|
305
|
+
bus_warned = False
|
|
306
|
+
|
|
307
|
+
for instance in instances:
|
|
308
|
+
node = instance.node
|
|
309
|
+
library = libraries[instance.file]
|
|
310
|
+
file = str(instance.file)
|
|
311
|
+
if not bus_warned and (node.child("bus") is not None or node.child("bus_entry") is not None):
|
|
312
|
+
notes.append("the schematic uses buses; signals carried through bus entries are not resolved")
|
|
313
|
+
bus_warned = True
|
|
314
|
+
|
|
315
|
+
local = UnionFind()
|
|
316
|
+
at_point: dict[tuple[float, float], list[object]] = defaultdict(list)
|
|
317
|
+
wires: list[tuple[tuple[float, float], tuple[float, float], int]] = []
|
|
318
|
+
pins: list[tuple[object, SchPin, LibPin, bool]] = []
|
|
319
|
+
labels: list[tuple[object, SchLabel, int]] = [] # (item, label, priority)
|
|
320
|
+
no_connects: list[tuple[float, float]] = []
|
|
321
|
+
|
|
322
|
+
for index, wire in enumerate(node.children("wire")):
|
|
323
|
+
pts = wire.child("pts")
|
|
324
|
+
xys = [tuple(float(a) for a in xy.atoms()[:2]) for xy in pts.children("xy")] if pts else []
|
|
325
|
+
for a, b in zip(xys, xys[1:]):
|
|
326
|
+
item = ("w", instance.path, index, len(wires))
|
|
327
|
+
wires.append((_key(*a), _key(*b), len(wires)))
|
|
328
|
+
at_point[_key(*a)].append(item)
|
|
329
|
+
at_point[_key(*b)].append(item)
|
|
330
|
+
local.find(item)
|
|
331
|
+
for index, junction in enumerate(node.children("junction")):
|
|
332
|
+
at = junction.xy("at")
|
|
333
|
+
if at:
|
|
334
|
+
item = ("j", instance.path, index)
|
|
335
|
+
at_point[_key(at[0], at[1])].append(item)
|
|
336
|
+
local.find(item)
|
|
337
|
+
for junction in node.children("no_connect"):
|
|
338
|
+
at = junction.xy("at")
|
|
339
|
+
if at:
|
|
340
|
+
no_connects.append(_key(at[0], at[1]))
|
|
341
|
+
|
|
342
|
+
for kind, priority in (("label", 4), ("global_label", 7), ("hierarchical_label", 3)):
|
|
343
|
+
for index, label in enumerate(node.children(kind)):
|
|
344
|
+
at = label.xy("at")
|
|
345
|
+
if not at or len(label) < 2 or not isinstance(label[1], str):
|
|
346
|
+
continue
|
|
347
|
+
item = ("l", instance.path, kind, index)
|
|
348
|
+
lab = SchLabel(kind={"label": "local", "global_label": "global",
|
|
349
|
+
"hierarchical_label": "hierarchical"}[kind],
|
|
350
|
+
text=label[1], sheet_file=file, sheet_path=instance.names,
|
|
351
|
+
x=at[0], y=at[1], line=label.line)
|
|
352
|
+
labels.append((item, lab, priority))
|
|
353
|
+
at_point[_key(at[0], at[1])].append(item)
|
|
354
|
+
local.find(item)
|
|
355
|
+
for child_path, sheet_pins in instance.sheet_pins.items():
|
|
356
|
+
for index, (text, (x, y), line) in enumerate(sheet_pins):
|
|
357
|
+
item = ("s", instance.path, child_path, index)
|
|
358
|
+
lab = SchLabel(kind="sheet_pin", text=text, sheet_file=file, sheet_path=instance.names,
|
|
359
|
+
x=x, y=y, line=line)
|
|
360
|
+
labels.append((item, lab, 2))
|
|
361
|
+
at_point[_key(x, y)].append(item)
|
|
362
|
+
local.find(item)
|
|
363
|
+
# The pin joins the child's hierarchical labels of the same name.
|
|
364
|
+
graph.union(("sheetpin", instance.path, child_path, index), ("L", child_path, text))
|
|
365
|
+
|
|
366
|
+
for symbol in node.children("symbol"):
|
|
367
|
+
lib_name = symbol.value("lib_name") or symbol.value("lib_id") or ""
|
|
368
|
+
lib = library.get(lib_name) or library.get(symbol.value("lib_id") or "")
|
|
369
|
+
ref, unit = _instance_reference(symbol, instance.path, legacy)
|
|
370
|
+
at = symbol.xy("at") or (0.0, 0.0, 0.0)
|
|
371
|
+
mirror = symbol.value("mirror")
|
|
372
|
+
style = int(float(symbol.value("body_style") or symbol.value("convert") or 1))
|
|
373
|
+
properties = _properties(symbol)
|
|
374
|
+
power = bool(lib and lib.power)
|
|
375
|
+
component = components.get(ref)
|
|
376
|
+
if component is None:
|
|
377
|
+
component = Component(
|
|
378
|
+
ref=ref, value=properties.get("Value", ""), lib_id=symbol.value("lib_id") or "",
|
|
379
|
+
footprint=properties.get("Footprint", ""), sheet_file=file, sheet_path=instance.names,
|
|
380
|
+
line=symbol.line, properties=properties, power=power or ref.startswith("#"),
|
|
381
|
+
dnp=symbol.flag("dnp"),
|
|
382
|
+
in_bom=symbol.value("in_bom", "yes") != "no",
|
|
383
|
+
on_board=symbol.value("on_board", "yes") != "no",
|
|
384
|
+
)
|
|
385
|
+
components[ref] = component
|
|
386
|
+
elif component.lib_id != (symbol.value("lib_id") or "") and not ref.startswith("#"):
|
|
387
|
+
notes.append(f"reference {ref} is used by two different symbols")
|
|
388
|
+
if "?" in ref and not ref.startswith("#"):
|
|
389
|
+
notes.append("the schematic has unannotated symbols (references with '?'); "
|
|
390
|
+
"they merge into one part until annotated")
|
|
391
|
+
if lib is None:
|
|
392
|
+
notes.append(f"{ref}: symbol {lib_name} is missing from lib_symbols; its pins are unknown")
|
|
393
|
+
continue
|
|
394
|
+
for lib_pin in lib.pins:
|
|
395
|
+
if lib_pin.unit not in (0, unit) or lib_pin.style not in (0, style):
|
|
396
|
+
continue
|
|
397
|
+
x, y = place(lib_pin.x, lib_pin.y, at[0], at[1], at[2], mirror)
|
|
398
|
+
pin = SchPin(ref=ref, number=lib_pin.number, name=lib_pin.name, type=lib_pin.type,
|
|
399
|
+
sheet_file=file, sheet_path=instance.names, x=round(x, 4), y=round(y, 4),
|
|
400
|
+
line=symbol.line, hidden=lib_pin.hidden)
|
|
401
|
+
item = ("p", instance.path, ref, lib_pin.number, len(pins))
|
|
402
|
+
pins.append((item, pin, lib_pin, power))
|
|
403
|
+
at_point[_key(x, y)].append(item)
|
|
404
|
+
local.find(item)
|
|
405
|
+
if power and lib_pin.type == "power_in":
|
|
406
|
+
value = properties.get("Value", "")
|
|
407
|
+
kind = "local_power" if lib.power == "local" else "power"
|
|
408
|
+
lab = SchLabel(kind=kind, text=value, sheet_file=file, sheet_path=instance.names,
|
|
409
|
+
x=x, y=y, line=symbol.line)
|
|
410
|
+
label_item = ("pw", instance.path, ref, lib_pin.number, len(pins))
|
|
411
|
+
labels.append((label_item, lab, 5 if kind == "local_power" else 6))
|
|
412
|
+
local.union(label_item, item)
|
|
413
|
+
elif lib_pin.hidden and lib_pin.type == "power_in" and lib_pin.name:
|
|
414
|
+
lab = SchLabel(kind="power", text=lib_pin.name, sheet_file=file, sheet_path=instance.names,
|
|
415
|
+
x=x, y=y, line=symbol.line)
|
|
416
|
+
label_item = ("hp", instance.path, ref, lib_pin.number, len(pins))
|
|
417
|
+
labels.append((label_item, lab, 6))
|
|
418
|
+
local.union(label_item, item)
|
|
419
|
+
|
|
420
|
+
# Everything at one point is connected (no-connect flags are kept out of at_point).
|
|
421
|
+
for items in at_point.values():
|
|
422
|
+
for other in items[1:]:
|
|
423
|
+
local.union(items[0], other)
|
|
424
|
+
# A label, junction or sheet pin anywhere on a wire joins it; pins and wire ends do not.
|
|
425
|
+
index = PointIndex(at_point.keys())
|
|
426
|
+
for a, b, number in wires:
|
|
427
|
+
wire_item = next(i for i in at_point[a] if i[0] == "w" and i[3] == number)
|
|
428
|
+
for point in index.near_segment(a, b, ON_WIRE_MM):
|
|
429
|
+
for item in at_point[point]:
|
|
430
|
+
if item[0] in INTERIOR_JOINS:
|
|
431
|
+
local.union(wire_item, item)
|
|
432
|
+
|
|
433
|
+
no_connect_points = set(no_connects)
|
|
434
|
+
for item, pin, _lib_pin, _power in pins:
|
|
435
|
+
root_item = ("c", instance.path, local.find(item))
|
|
436
|
+
if _key(pin.x, pin.y) in no_connect_points:
|
|
437
|
+
pin = dataclasses.replace(pin, no_connect=True)
|
|
438
|
+
cluster_nc.add(root_item)
|
|
439
|
+
cluster_pins[root_item].append(pin)
|
|
440
|
+
graph.find(root_item)
|
|
441
|
+
for item, label, priority in labels:
|
|
442
|
+
root_item = ("c", instance.path, local.find(item))
|
|
443
|
+
cluster_labels[root_item].append((label, priority))
|
|
444
|
+
graph.find(root_item)
|
|
445
|
+
if label.kind in ("local", "hierarchical", "local_power"):
|
|
446
|
+
graph.union(root_item, ("L", instance.path, label.text))
|
|
447
|
+
elif label.kind in ("global", "power"):
|
|
448
|
+
graph.union(root_item, ("G", label.text))
|
|
449
|
+
elif label.kind == "sheet_pin":
|
|
450
|
+
graph.union(root_item, ("sheetpin", instance.path, item[2], item[3]))
|
|
451
|
+
|
|
452
|
+
# Group clusters into nets.
|
|
453
|
+
members: dict[object, list[object]] = defaultdict(list)
|
|
454
|
+
for cluster in set(cluster_pins) | set(cluster_labels):
|
|
455
|
+
members[graph.find(cluster)].append(cluster)
|
|
456
|
+
|
|
457
|
+
nets: list[Net] = []
|
|
458
|
+
for clusters in members.values():
|
|
459
|
+
pins = sorted((p for c in clusters for p in cluster_pins.get(c, [])),
|
|
460
|
+
key=lambda p: (p.ref, _natural(p.number)))
|
|
461
|
+
named = [(label, priority) for c in clusters for label, priority in cluster_labels.get(c, [])]
|
|
462
|
+
if not pins and not named:
|
|
463
|
+
continue
|
|
464
|
+
name, label_text, power = _net_name(pins, named)
|
|
465
|
+
nets.append(Net(name=name, label=label_text, pins=pins,
|
|
466
|
+
labels=[label for label, _ in named], power=power,
|
|
467
|
+
no_connect=any(c in cluster_nc for c in clusters)))
|
|
468
|
+
|
|
469
|
+
# Two separate nets can still end up with one KiCad-style name (an auto name, rarely a
|
|
470
|
+
# label); keep them apart by suffixing, as KiCad does with its own collisions.
|
|
471
|
+
seen: dict[str, int] = {}
|
|
472
|
+
for net in sorted(nets, key=lambda n: (n.name, [(p.ref, p.number) for p in n.pins])):
|
|
473
|
+
count = seen.get(net.name, 0)
|
|
474
|
+
seen[net.name] = count + 1
|
|
475
|
+
if count:
|
|
476
|
+
net.name = f"{net.name}_{count}"
|
|
477
|
+
|
|
478
|
+
for net in nets:
|
|
479
|
+
for pin in net.pins:
|
|
480
|
+
component = components.get(pin.ref)
|
|
481
|
+
if component is not None:
|
|
482
|
+
component.pins.append(pin)
|
|
483
|
+
counted = []
|
|
484
|
+
for net in nets:
|
|
485
|
+
connections = len(net.parts)
|
|
486
|
+
net.labels = [dataclasses.replace(label, connections=connections) for label in net.labels]
|
|
487
|
+
counted.extend(net.labels)
|
|
488
|
+
schematic.labels = sorted(counted, key=lambda lab: (lab.sheet_file, lab.line, lab.text))
|
|
489
|
+
schematic.components = dict(sorted(components.items(), key=lambda kv: _natural(kv[0])))
|
|
490
|
+
schematic.nets = sorted(nets, key=lambda n: _natural(n.name))
|
|
491
|
+
schematic.notes = sorted(set(notes))
|
|
492
|
+
schematic.index()
|
|
493
|
+
return schematic
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _natural(text: str) -> tuple:
|
|
497
|
+
return tuple((0, int(t), "") if t.isdigit() else (1, 0, t) for t in re.split(r"(\d+)", text or "") if t)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _net_name(pins: list[SchPin], named: list[tuple[SchLabel, int]]) -> tuple[str, str | None, bool]:
|
|
501
|
+
"""KiCad-style net name: the highest-priority driver wins (global label, power symbol,
|
|
502
|
+
local power, local label, hierarchical label, sheet pin), the highest sheet level and then
|
|
503
|
+
the smallest text breaking ties. Unnamed nets are named after their first pin."""
|
|
504
|
+
power = any(label.kind in ("power", "local_power") for label, _ in named)
|
|
505
|
+
if named:
|
|
506
|
+
label, _priority = min(named, key=lambda lp: (-lp[1], lp[0].sheet_path.count("/"), lp[0].text))
|
|
507
|
+
if label.kind in ("global", "power"):
|
|
508
|
+
return label.text, label.text, power
|
|
509
|
+
return f"{label.sheet_path}{label.text}", label.text, power
|
|
510
|
+
parts = [p for p in pins if not p.ref.startswith("#")] or pins
|
|
511
|
+
# Measured against kicad-cli: a named pin wins over an unnamed one, then the reference that
|
|
512
|
+
# sorts first as text (R10 before R9), then the pin number.
|
|
513
|
+
first = min(parts, key=lambda p: (not p.name, p.ref, _natural(p.number)))
|
|
514
|
+
pad = f"{first.name}-Pad{first.number}" if first.name else f"Pad{first.number}"
|
|
515
|
+
if len(parts) == 1:
|
|
516
|
+
return f"unconnected-({first.ref}-{pad})", None, False
|
|
517
|
+
return f"Net-({first.ref}-{first.name or 'Pad' + first.number})", None, False
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""KiCad's S-expression files, read with the standard library.
|
|
2
|
+
|
|
3
|
+
`.kicad_sch`, `.kicad_pcb`, `.kicad_sym`, `.kicad_mod` and the `kicadsexpr` netlist are one
|
|
4
|
+
syntax: lists in parentheses, bare atoms (`yes`, `smd`, `1.27`) and double-quoted strings with
|
|
5
|
+
backslash escapes. A list remembers the line its opening parenthesis is on, so a finding can
|
|
6
|
+
point at the line of the symbol, wire or pad it is about.
|
|
7
|
+
|
|
8
|
+
Quoted and bare atoms are both returned as `str`. KiCad does not distinguish them by meaning
|
|
9
|
+
(`(hide yes)` and `(hide "yes")` read the same), and keeping one type keeps every caller simple.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import bisect
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_TOKEN = re.compile(r'\(|\)|"(?:[^"\\]|\\.)*"|[^\s()"]+', re.S)
|
|
20
|
+
_ESCAPE = re.compile(r"\\(.)", re.S)
|
|
21
|
+
_ESCAPES = {"n": "\n", "t": "\t", "r": "\r"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SexprError(ValueError):
|
|
25
|
+
"""The text is not a well-formed S-expression."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Node(list):
|
|
29
|
+
"""One parenthesised list: `node[0]` is its head atom, the rest are atoms and Nodes."""
|
|
30
|
+
|
|
31
|
+
__slots__ = ("line",)
|
|
32
|
+
|
|
33
|
+
def __init__(self, line: int = 0) -> None:
|
|
34
|
+
super().__init__()
|
|
35
|
+
self.line = line
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def head(self) -> str:
|
|
39
|
+
return self[0] if self and isinstance(self[0], str) else ""
|
|
40
|
+
|
|
41
|
+
def children(self, name: str | None = None) -> Iterator[Node]:
|
|
42
|
+
"""Child lists, optionally only those whose head is `name`."""
|
|
43
|
+
for item in self:
|
|
44
|
+
if isinstance(item, Node) and (name is None or item.head == name):
|
|
45
|
+
yield item
|
|
46
|
+
|
|
47
|
+
def child(self, name: str) -> Node | None:
|
|
48
|
+
return next(self.children(name), None)
|
|
49
|
+
|
|
50
|
+
def atoms(self) -> list[str]:
|
|
51
|
+
"""The atoms after the head, skipping nested lists."""
|
|
52
|
+
return [item for item in self[1:] if isinstance(item, str)]
|
|
53
|
+
|
|
54
|
+
def value(self, name: str, default: str | None = None) -> str | None:
|
|
55
|
+
"""The first atom of child `name`: `value("layer")` on `(layer "F.Cu")` is `F.Cu`."""
|
|
56
|
+
found = self.child(name)
|
|
57
|
+
if found is None:
|
|
58
|
+
return default
|
|
59
|
+
atoms = found.atoms()
|
|
60
|
+
return atoms[0] if atoms else default
|
|
61
|
+
|
|
62
|
+
def number(self, name: str, default: float | None = None) -> float | None:
|
|
63
|
+
text = self.value(name)
|
|
64
|
+
try:
|
|
65
|
+
return float(text) if text is not None else default
|
|
66
|
+
except ValueError:
|
|
67
|
+
return default
|
|
68
|
+
|
|
69
|
+
def flag(self, name: str) -> bool:
|
|
70
|
+
"""A boolean KiCad writes as `(name yes)` (KiCad 9+) or as a bare `name` atom (older)."""
|
|
71
|
+
if name in self.atoms():
|
|
72
|
+
return True
|
|
73
|
+
found = self.child(name)
|
|
74
|
+
if found is None:
|
|
75
|
+
return False
|
|
76
|
+
atoms = found.atoms()
|
|
77
|
+
return not atoms or atoms[0] in ("yes", "true")
|
|
78
|
+
|
|
79
|
+
def xy(self, name: str = "at") -> tuple[float, float, float] | None:
|
|
80
|
+
"""`(at x y [angle])` as floats; None when absent."""
|
|
81
|
+
found = self.child(name)
|
|
82
|
+
if found is None:
|
|
83
|
+
return None
|
|
84
|
+
numbers = [float(a) for a in found.atoms()[:3]]
|
|
85
|
+
while len(numbers) < 3:
|
|
86
|
+
numbers.append(0.0)
|
|
87
|
+
return numbers[0], numbers[1], numbers[2]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def unquote(token: str) -> str:
|
|
91
|
+
return _ESCAPE.sub(lambda m: _ESCAPES.get(m.group(1), m.group(1)), token[1:-1])
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse(text: str) -> Node:
|
|
95
|
+
"""Parse one top-level list. Raises SexprError on unbalanced or empty input."""
|
|
96
|
+
newlines = [m.start() for m in re.finditer("\n", text)]
|
|
97
|
+
stack: list[Node] = []
|
|
98
|
+
root: Node | None = None
|
|
99
|
+
for match in _TOKEN.finditer(text):
|
|
100
|
+
token = match.group()
|
|
101
|
+
if token == "(":
|
|
102
|
+
node = Node(bisect.bisect_right(newlines, match.start()) + 1)
|
|
103
|
+
if stack:
|
|
104
|
+
stack[-1].append(node)
|
|
105
|
+
elif root is not None:
|
|
106
|
+
raise SexprError("more than one top-level list")
|
|
107
|
+
else:
|
|
108
|
+
root = node
|
|
109
|
+
stack.append(node)
|
|
110
|
+
elif token == ")":
|
|
111
|
+
if not stack:
|
|
112
|
+
raise SexprError(f"unbalanced ')' at offset {match.start()}")
|
|
113
|
+
stack.pop()
|
|
114
|
+
elif not stack:
|
|
115
|
+
raise SexprError(f"atom outside a list at offset {match.start()}")
|
|
116
|
+
else:
|
|
117
|
+
stack[-1].append(unquote(token) if token.startswith('"') else token)
|
|
118
|
+
if stack:
|
|
119
|
+
raise SexprError(f"{len(stack)} list(s) not closed")
|
|
120
|
+
if root is None:
|
|
121
|
+
raise SexprError("no S-expression found")
|
|
122
|
+
return root
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def load(path: Path | str) -> Node:
|
|
126
|
+
"""Parse a file. KiCad writes UTF-8; a byte order mark is tolerated."""
|
|
127
|
+
try:
|
|
128
|
+
text = Path(path).read_text(encoding="utf-8-sig")
|
|
129
|
+
except UnicodeDecodeError as exc:
|
|
130
|
+
raise SexprError(f"{Path(path).name} is not UTF-8: {exc}") from exc
|
|
131
|
+
return parse(text)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def quote(text: str) -> str:
|
|
135
|
+
"""Quote a string the way KiCad writes it (for the synthetic fixture writer and tests)."""
|
|
136
|
+
return '"' + text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + '"'
|