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,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") + '"'