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,249 @@
|
|
|
1
|
+
"""Read project-listed schematic sheets and expand their sheet-symbol instances.
|
|
2
|
+
|
|
3
|
+
This is structural discovery, not electrical connectivity or compiled annotation.
|
|
4
|
+
Every child reference is resolved against the project inventory before instances
|
|
5
|
+
are expanded. Reusing a sheet creates separate instance paths; cycles and ambiguous
|
|
6
|
+
references raise SchDocError instead of silently falling back to global names.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import configparser
|
|
12
|
+
import dataclasses
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path, PureWindowsPath
|
|
15
|
+
|
|
16
|
+
from pcba.parsers.altium.records import SchDocError, load_any_records, text_of
|
|
17
|
+
|
|
18
|
+
REC_SHEET_SYMBOL = "22"
|
|
19
|
+
REC_SHEET_ENTRY = "23"
|
|
20
|
+
REC_SHEET_NAME = "32"
|
|
21
|
+
REC_SHEET_FILE_NAME = "35"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
25
|
+
class SheetSymbol:
|
|
26
|
+
"""One source symbol and its owned entry records."""
|
|
27
|
+
|
|
28
|
+
index: int
|
|
29
|
+
name: str
|
|
30
|
+
child: Path
|
|
31
|
+
entries: tuple[dict[str, str], ...]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
35
|
+
class SheetInstance:
|
|
36
|
+
"""One occurrence of a project sheet, identified by its symbol path."""
|
|
37
|
+
|
|
38
|
+
path: tuple[str, ...]
|
|
39
|
+
sheet: Path
|
|
40
|
+
parent: tuple[str, ...] | None
|
|
41
|
+
symbol_index: int | None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
45
|
+
class ProjectSheets:
|
|
46
|
+
"""Source inventory and instances; no product-model entities or compiled nets."""
|
|
47
|
+
|
|
48
|
+
project: Path
|
|
49
|
+
records: dict[Path, list[dict[str, str]]]
|
|
50
|
+
symbols: dict[Path, tuple[SheetSymbol, ...]]
|
|
51
|
+
instances: tuple[SheetInstance, ...]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read_project(path: Path | str) -> ProjectSheets:
|
|
55
|
+
"""Read only the schematic documents listed in a .PrjPcb and expand their instances."""
|
|
56
|
+
path = Path(path).resolve()
|
|
57
|
+
documents = _documents(path)
|
|
58
|
+
records = {document: load_any_records(document) for document in documents}
|
|
59
|
+
symbols = {document: _symbols(document, stream, documents, path.parent) for document, stream in records.items()}
|
|
60
|
+
_check_cycles(symbols)
|
|
61
|
+
children = {symbol.child for found in symbols.values() for symbol in found}
|
|
62
|
+
roots = [document for document in documents if document not in children]
|
|
63
|
+
instances: list[SheetInstance] = []
|
|
64
|
+
pending = [
|
|
65
|
+
SheetInstance((document.relative_to(path.parent).as_posix(),), document, None, None)
|
|
66
|
+
for document in reversed(roots)
|
|
67
|
+
]
|
|
68
|
+
while pending:
|
|
69
|
+
instance = pending.pop()
|
|
70
|
+
instances.append(instance)
|
|
71
|
+
pending.extend(
|
|
72
|
+
SheetInstance(instance.path + (symbol.name,), symbol.child, instance.path, symbol.index)
|
|
73
|
+
for symbol in reversed(symbols[instance.sheet])
|
|
74
|
+
)
|
|
75
|
+
return ProjectSheets(path, records, symbols, tuple(instances))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def to_dict(project: ProjectSheets) -> dict:
|
|
79
|
+
"""Return structural source facts, explicitly excluding electrical and compiled results."""
|
|
80
|
+
def relative(path: Path) -> str:
|
|
81
|
+
return path.relative_to(project.project.parent).as_posix()
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
"project": project.project.name,
|
|
85
|
+
"electrical_connectivity": "not_resolved",
|
|
86
|
+
"sheets": [relative(path) for path in project.records],
|
|
87
|
+
"instances": [
|
|
88
|
+
{
|
|
89
|
+
"path": list(instance.path),
|
|
90
|
+
"sheet": relative(instance.sheet),
|
|
91
|
+
"parent": list(instance.parent) if instance.parent is not None else None,
|
|
92
|
+
"symbol_record": instance.symbol_index,
|
|
93
|
+
}
|
|
94
|
+
for instance in project.instances
|
|
95
|
+
],
|
|
96
|
+
"sheet_symbols": {
|
|
97
|
+
relative(path): [
|
|
98
|
+
{
|
|
99
|
+
"record": symbol.index,
|
|
100
|
+
"name": symbol.name,
|
|
101
|
+
"child": relative(symbol.child),
|
|
102
|
+
"entries": [
|
|
103
|
+
(fields.get("%UTF8%Name") or fields.get("Name", "")).strip() for fields in symbol.entries
|
|
104
|
+
],
|
|
105
|
+
}
|
|
106
|
+
for symbol in symbols
|
|
107
|
+
]
|
|
108
|
+
for path, symbols in project.symbols.items()
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _documents(project: Path) -> tuple[Path, ...]:
|
|
114
|
+
try:
|
|
115
|
+
data = project.read_bytes()
|
|
116
|
+
try:
|
|
117
|
+
text = data.decode("utf-8-sig")
|
|
118
|
+
except UnicodeDecodeError:
|
|
119
|
+
text = data.decode("latin-1")
|
|
120
|
+
config = configparser.ConfigParser(interpolation=None, strict=True)
|
|
121
|
+
config.read_string(text)
|
|
122
|
+
except (OSError, configparser.Error) as exc:
|
|
123
|
+
raise SchDocError(f"{project}: cannot read project ({exc})") from exc
|
|
124
|
+
documents: dict[str, Path] = {}
|
|
125
|
+
for section in config.sections():
|
|
126
|
+
if not re.fullmatch(r"Document\d+", section, re.I):
|
|
127
|
+
continue
|
|
128
|
+
value = config.get(section, "DocumentPath", fallback="").strip()
|
|
129
|
+
if not value:
|
|
130
|
+
raise SchDocError(f"{project}: {section} has no DocumentPath")
|
|
131
|
+
if PureWindowsPath(value).suffix.lower() != ".schdoc":
|
|
132
|
+
continue
|
|
133
|
+
document = _case_file(project.parent, value)
|
|
134
|
+
identity = document.relative_to(project.parent).as_posix().casefold()
|
|
135
|
+
if identity in documents:
|
|
136
|
+
raise SchDocError(f"{project}: duplicate schematic document {value!r}")
|
|
137
|
+
if not document.is_file():
|
|
138
|
+
raise SchDocError(f"{project}: missing schematic document {value!r}")
|
|
139
|
+
documents[identity] = document
|
|
140
|
+
if not documents:
|
|
141
|
+
raise SchDocError(f"{project}: no schematic documents listed")
|
|
142
|
+
return tuple(documents.values())
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _inside(root: Path, value: str) -> Path:
|
|
146
|
+
windows = PureWindowsPath(value)
|
|
147
|
+
if windows.drive or windows.root or Path(value).is_absolute():
|
|
148
|
+
raise SchDocError(f"absolute schematic reference is not supported: {value!r}")
|
|
149
|
+
result = root.joinpath(*windows.parts).resolve()
|
|
150
|
+
if not result.is_relative_to(root):
|
|
151
|
+
raise SchDocError(f"schematic reference leaves the project directory: {value!r}")
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _case_file(root: Path, value: str) -> Path:
|
|
156
|
+
"""Resolve every listed path segment with Windows case semantics, without first-wins."""
|
|
157
|
+
logical = _inside(root, value)
|
|
158
|
+
current = root
|
|
159
|
+
for part in logical.relative_to(root).parts:
|
|
160
|
+
try:
|
|
161
|
+
matches = [entry for entry in current.iterdir() if entry.name.casefold() == part.casefold()]
|
|
162
|
+
except OSError as exc:
|
|
163
|
+
raise SchDocError(f"cannot read schematic path {value!r}: {exc}") from exc
|
|
164
|
+
if len(matches) != 1:
|
|
165
|
+
reason = "ambiguous" if matches else "missing"
|
|
166
|
+
raise SchDocError(f"{reason} schematic document {value!r}")
|
|
167
|
+
current = matches[0]
|
|
168
|
+
current = current.resolve()
|
|
169
|
+
if not current.is_relative_to(root):
|
|
170
|
+
raise SchDocError(f"schematic reference leaves the project directory: {value!r}")
|
|
171
|
+
return current
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _child(document: Path, value: str, documents: tuple[Path, ...], root: Path) -> Path:
|
|
175
|
+
windows = PureWindowsPath(value)
|
|
176
|
+
if windows.drive or windows.root or Path(value).is_absolute():
|
|
177
|
+
raise SchDocError(f"absolute schematic reference is not supported: {value!r}")
|
|
178
|
+
references = {
|
|
179
|
+
candidate.relative_to(root).as_posix().casefold()
|
|
180
|
+
for base in (root, document.parent)
|
|
181
|
+
if (candidate := base.joinpath(*windows.parts).resolve()).is_relative_to(root)
|
|
182
|
+
}
|
|
183
|
+
if not references:
|
|
184
|
+
raise SchDocError(f"schematic reference leaves the project directory: {value!r}")
|
|
185
|
+
matches = [candidate for candidate in documents if candidate.relative_to(root).as_posix().casefold() in references]
|
|
186
|
+
if not matches and len(PureWindowsPath(value).parts) == 1:
|
|
187
|
+
matches = [candidate for candidate in documents if candidate.name.casefold() == value.casefold()]
|
|
188
|
+
if len(matches) != 1:
|
|
189
|
+
reason = "ambiguous" if matches else "unlisted or missing"
|
|
190
|
+
raise SchDocError(f"{document}: {reason} child schematic {value!r}")
|
|
191
|
+
return matches[0]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _symbols(
|
|
195
|
+
document: Path, records: list[dict[str, str]], documents: tuple[Path, ...], root: Path
|
|
196
|
+
) -> tuple[SheetSymbol, ...]:
|
|
197
|
+
indices = {index for index, fields in enumerate(records) if fields.get("RECORD") == REC_SHEET_SYMBOL}
|
|
198
|
+
owned: dict[int, list[dict[str, str]]] = {index: [] for index in indices}
|
|
199
|
+
for fields in records:
|
|
200
|
+
if fields.get("RECORD") not in (REC_SHEET_NAME, REC_SHEET_FILE_NAME, REC_SHEET_ENTRY):
|
|
201
|
+
continue
|
|
202
|
+
value = fields.get("OwnerIndex", "")
|
|
203
|
+
if not value.strip():
|
|
204
|
+
raise SchDocError(f"{document}: sheet-symbol child has no owner")
|
|
205
|
+
index = int(value) + 1
|
|
206
|
+
if index not in indices:
|
|
207
|
+
raise SchDocError(f"{document}: sheet-symbol child has an invalid owner")
|
|
208
|
+
owned[index].append(fields)
|
|
209
|
+
out: list[SheetSymbol] = []
|
|
210
|
+
names: set[str] = set()
|
|
211
|
+
for index in sorted(indices):
|
|
212
|
+
name = _one_text(document, owned[index], REC_SHEET_NAME)
|
|
213
|
+
filename = _one_text(document, owned[index], REC_SHEET_FILE_NAME)
|
|
214
|
+
if re.match(r"repeat\s*\(", name, re.I):
|
|
215
|
+
raise SchDocError(f"{document}: Repeat sheet-symbol expansion is not implemented")
|
|
216
|
+
identity = name.casefold()
|
|
217
|
+
if identity in names:
|
|
218
|
+
raise SchDocError(f"{document}: duplicate sheet-symbol name {name!r}")
|
|
219
|
+
names.add(identity)
|
|
220
|
+
entries = tuple(fields for fields in owned[index] if fields.get("RECORD") == REC_SHEET_ENTRY)
|
|
221
|
+
out.append(SheetSymbol(index, name, _child(document, filename, documents, root), entries))
|
|
222
|
+
return tuple(out)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _one_text(document: Path, records: list[dict[str, str]], kind: str) -> str:
|
|
226
|
+
values = [text_of(fields) for fields in records if fields.get("RECORD") == kind]
|
|
227
|
+
if len(values) != 1 or not values[0]:
|
|
228
|
+
raise SchDocError(f"{document}: sheet symbol needs exactly one nonempty record {kind}")
|
|
229
|
+
return values[0]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _check_cycles(symbols: dict[Path, tuple[SheetSymbol, ...]]) -> None:
|
|
233
|
+
complete: set[Path] = set()
|
|
234
|
+
for start in symbols:
|
|
235
|
+
active: set[Path] = set()
|
|
236
|
+
pending: list[tuple[Path, bool]] = [(start, False)]
|
|
237
|
+
while pending:
|
|
238
|
+
document, leaving = pending.pop()
|
|
239
|
+
if leaving:
|
|
240
|
+
active.remove(document)
|
|
241
|
+
complete.add(document)
|
|
242
|
+
continue
|
|
243
|
+
if document in active:
|
|
244
|
+
raise SchDocError(f"cyclic schematic hierarchy at {document.name!r}")
|
|
245
|
+
if document in complete:
|
|
246
|
+
continue
|
|
247
|
+
active.add(document)
|
|
248
|
+
pending.append((document, True))
|
|
249
|
+
pending.extend((symbol.child, False) for symbol in reversed(symbols[document]))
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Read the record stream out of a binary Altium `.SchDoc`.
|
|
2
|
+
|
|
3
|
+
Format notes — each of these silently corrupts the result if ignored
|
|
4
|
+
-------------------------------------------------------------------
|
|
5
|
+
- `.SchDoc` is an OLE2/CFB container; the records live in the "FileHeader" stream.
|
|
6
|
+
- Records are uint32-LE length + payload; payload is Latin-1 with embedded NULs and splits
|
|
7
|
+
into "|KEY=VALUE" fields. Altium writes ANSI, never UTF-8. Some text fields appear as
|
|
8
|
+
"%UTF8%Text" instead of "Text".
|
|
9
|
+
- `OwnerIndex` is 0-based over the records, so the component index is `OwnerIndex + 1`.
|
|
10
|
+
- A component's designator ("U1") is NOT a field of the component record. It is a child
|
|
11
|
+
record (RECORD=34) whose `Text` holds it. The component record's `LibReference` is the
|
|
12
|
+
symbol name, which for an MCU is the part number — reading that as the designator makes
|
|
13
|
+
every finding say "STM32...PD8" where it should say "U1.PD8".
|
|
14
|
+
- A pin's `Location` is the BODY-ATTACH end. The electrical end is `PinLength` away along
|
|
15
|
+
the direction in the low 2 bits of `PinConglomerate`.
|
|
16
|
+
- Coordinates are an integer plus an optional millionths sub-step in `<key>_Frac`.
|
|
17
|
+
|
|
18
|
+
That list is the asset. The code below is the easy part; knowing these six things is what
|
|
19
|
+
took the time.
|
|
20
|
+
|
|
21
|
+
ASCII schematics
|
|
22
|
+
----------------
|
|
23
|
+
Altium can also save a schematic as text: a file starting
|
|
24
|
+
"|HEADER=Protel for Windows - Schematic Capture Ascii File", one record per line, the same
|
|
25
|
+
"|KEY=VALUE" fields and coordinate units. Its header line is record 0 exactly like the
|
|
26
|
+
binary stream's header record, so `OwnerIndex` means the same thing in both. Everything
|
|
27
|
+
from the "|HEADER=Icon storage" line on is embedded image data, not records. Lines split on
|
|
28
|
+
LF only: Latin-1 decodes byte 0x85 to U+0085, which `str.splitlines` treats as a line
|
|
29
|
+
break. `load_records` stays binary-only, which is the pin map's contract; `load_any_records`
|
|
30
|
+
accepts both.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import re
|
|
36
|
+
import struct
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
import olefile
|
|
40
|
+
|
|
41
|
+
# Altium record type ids, as they appear in the RECORD field.
|
|
42
|
+
REC_COMPONENT = "1"
|
|
43
|
+
REC_PIN = "2"
|
|
44
|
+
REC_POWER_PORT = "17"
|
|
45
|
+
REC_PORT = "18"
|
|
46
|
+
REC_NET_LABEL = "25"
|
|
47
|
+
REC_WIRE = "27"
|
|
48
|
+
REC_JUNCTION = "29"
|
|
49
|
+
REC_DESIGNATOR = "34"
|
|
50
|
+
REC_PARAMETER = "41"
|
|
51
|
+
REC_IMPLEMENTATION = "45"
|
|
52
|
+
|
|
53
|
+
# First bytes of a schematic saved in Altium's ASCII format, and the line that ends its
|
|
54
|
+
# records (embedded images follow it).
|
|
55
|
+
ASCII_SIGNATURE = b"|HEADER=Protel for Windows - Schematic Capture Ascii File"
|
|
56
|
+
_ICON_STORAGE = b"|HEADER=Icon storage"
|
|
57
|
+
|
|
58
|
+
# Low 2 bits of PinConglomerate give the direction the pin points away from the body.
|
|
59
|
+
PIN_DIRECTION = {0: (1, 0), 1: (0, 1), 2: (-1, 0), 3: (0, -1)}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SchDocError(RuntimeError):
|
|
63
|
+
"""A controlled failure while reading a `.SchDoc`."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_records(path: Path | str) -> list[dict[str, str]]:
|
|
67
|
+
"""Return the FileHeader record stream as a list of field dicts."""
|
|
68
|
+
try:
|
|
69
|
+
ole = olefile.OleFileIO(str(path))
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
raise SchDocError(f"cannot read {path}: {exc}") from exc
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
# olefile reports a damaged header with ValueError, IndexError, struct.error or even
|
|
74
|
+
# MemoryError. One damaged sheet must become a note, not end every review.
|
|
75
|
+
raise SchDocError(f"{path}: damaged OLE2 container ({type(exc).__name__})") from exc
|
|
76
|
+
try:
|
|
77
|
+
if not ole.exists("FileHeader"):
|
|
78
|
+
raise SchDocError(f"{path}: no FileHeader stream — not a schematic document?")
|
|
79
|
+
buf = ole.openstream("FileHeader").read()
|
|
80
|
+
except SchDocError:
|
|
81
|
+
raise
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
raise SchDocError(f"{path}: damaged OLE2 container ({type(exc).__name__})") from exc
|
|
84
|
+
finally:
|
|
85
|
+
ole.close()
|
|
86
|
+
|
|
87
|
+
records = split_records(buf, source=str(path))
|
|
88
|
+
try:
|
|
89
|
+
validate_fields(records)
|
|
90
|
+
except SchDocError as exc:
|
|
91
|
+
raise SchDocError(f"{path}: {exc}") from exc
|
|
92
|
+
return records
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Geometry and ownership fields the extractor reads as integers. The TypeScript decoder
|
|
96
|
+
# applies the same check (`validateRecordFields`); both run the shared conformance corpus.
|
|
97
|
+
_INTEGER_FIELD = re.compile(
|
|
98
|
+
r"RECORD|OwnerIndex|OwnerPartId|CurrentPartId|PinLength|PinConglomerate|LocationCount"
|
|
99
|
+
r"|Location\.[XY](?:_Frac)?|[XY]\d+(?:_Frac)?"
|
|
100
|
+
)
|
|
101
|
+
# At most ten digits: every Altium coordinate fits an int32, and int() refuses strings over
|
|
102
|
+
# 4300 digits with a ValueError the callers do not expect.
|
|
103
|
+
_INTEGER_VALUE = re.compile(r"[+-]?[0-9]{1,10}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def validate_fields(records: list[dict[str, str]]) -> None:
|
|
107
|
+
"""Reject records whose integer fields are not plain ASCII integers.
|
|
108
|
+
|
|
109
|
+
Deliberately stricter than `int()`, which also accepts `1_000` and non-ASCII digits:
|
|
110
|
+
both decoders must refuse the same inputs.
|
|
111
|
+
"""
|
|
112
|
+
for index, fields in enumerate(records):
|
|
113
|
+
for field, value in fields.items():
|
|
114
|
+
if value and _INTEGER_FIELD.fullmatch(field) and not _INTEGER_VALUE.fullmatch(value.strip(" \t")):
|
|
115
|
+
raise SchDocError(f"record {index}: invalid integer field {field}")
|
|
116
|
+
count = fields.get("LocationCount", "").strip(" \t")
|
|
117
|
+
if count and not 0 <= int(count) <= len(fields):
|
|
118
|
+
raise SchDocError(f"record {index}: invalid LocationCount")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def split_records(buf: bytes, *, source: str = "record stream") -> list[dict[str, str]]:
|
|
122
|
+
"""Decode an Altium record stream without its OLE2 container."""
|
|
123
|
+
|
|
124
|
+
out: list[dict[str, str]] = []
|
|
125
|
+
offset = 0
|
|
126
|
+
while offset + 4 <= len(buf):
|
|
127
|
+
(length,) = struct.unpack_from("<I", buf, offset)
|
|
128
|
+
offset += 4
|
|
129
|
+
if length == 0:
|
|
130
|
+
if any(buf[offset:]):
|
|
131
|
+
raise SchDocError(f"{source}: nonzero data after record-stream terminator")
|
|
132
|
+
break
|
|
133
|
+
if offset + length > len(buf):
|
|
134
|
+
raise SchDocError(
|
|
135
|
+
f"{source}: truncated record {len(out)} declares {length} bytes, "
|
|
136
|
+
f"only {len(buf) - offset} remain"
|
|
137
|
+
)
|
|
138
|
+
text = buf[offset : offset + length].replace(b"\x00", b"").decode("latin-1")
|
|
139
|
+
offset += length
|
|
140
|
+
out.append(_split_fields(text))
|
|
141
|
+
if offset < len(buf) and len(buf) - offset < 4 and any(buf[offset:]):
|
|
142
|
+
raise SchDocError(f"{source}: truncated record-length prefix")
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _split_fields(text: str) -> dict[str, str]:
|
|
147
|
+
"""One record's "|KEY=VALUE|KEY=VALUE" payload as a dict; anything else is empty."""
|
|
148
|
+
fields: dict[str, str] = {}
|
|
149
|
+
if text.startswith("|"):
|
|
150
|
+
for item in text.split("|"):
|
|
151
|
+
eq = item.find("=")
|
|
152
|
+
if eq > 0:
|
|
153
|
+
fields[item[:eq]] = item[eq + 1 :]
|
|
154
|
+
return fields
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def split_ascii_records(buf: bytes) -> list[dict[str, str]]:
|
|
158
|
+
"""Decode an ASCII `.SchDoc`: one record per line, up to the icon-storage section.
|
|
159
|
+
|
|
160
|
+
Blank lines are not records. A line that is not a "|KEY=VALUE" record still counts as
|
|
161
|
+
one (an empty dict), as in the binary decoder, so `OwnerIndex` stays aligned.
|
|
162
|
+
"""
|
|
163
|
+
out: list[dict[str, str]] = []
|
|
164
|
+
for line in buf.split(b"\n"):
|
|
165
|
+
line = line.rstrip(b"\r")
|
|
166
|
+
if line.startswith(_ICON_STORAGE):
|
|
167
|
+
break
|
|
168
|
+
if not line.strip():
|
|
169
|
+
continue
|
|
170
|
+
out.append(_split_fields(line.replace(b"\x00", b"").decode("latin-1")))
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def load_ascii_records(path: Path | str) -> list[dict[str, str]]:
|
|
175
|
+
"""Return the records of an ASCII `.SchDoc`, validated like the binary stream."""
|
|
176
|
+
try:
|
|
177
|
+
buf = Path(path).read_bytes()
|
|
178
|
+
except OSError as exc:
|
|
179
|
+
raise SchDocError(f"cannot read {path}: {exc}") from exc
|
|
180
|
+
if not buf.startswith(ASCII_SIGNATURE):
|
|
181
|
+
raise SchDocError(f"{path}: not an ASCII schematic document")
|
|
182
|
+
records = split_ascii_records(buf)
|
|
183
|
+
try:
|
|
184
|
+
validate_fields(records)
|
|
185
|
+
except SchDocError as exc:
|
|
186
|
+
raise SchDocError(f"{path}: {exc}") from exc
|
|
187
|
+
return records
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def load_any_records(path: Path | str) -> list[dict[str, str]]:
|
|
191
|
+
"""Records of a binary or an ASCII `.SchDoc`, chosen by the file's first bytes."""
|
|
192
|
+
try:
|
|
193
|
+
with open(path, "rb") as fh:
|
|
194
|
+
head = fh.read(len(ASCII_SIGNATURE))
|
|
195
|
+
except OSError as exc:
|
|
196
|
+
raise SchDocError(f"cannot read {path}: {exc}") from exc
|
|
197
|
+
if head == ASCII_SIGNATURE:
|
|
198
|
+
return load_ascii_records(path)
|
|
199
|
+
return load_records(path)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def coord(fields: dict[str, str], key: str) -> float:
|
|
203
|
+
"""Altium stores an integer coordinate plus an optional millionths sub-step."""
|
|
204
|
+
whole = fields.get(key)
|
|
205
|
+
frac = fields.get(key + "_Frac")
|
|
206
|
+
value = int(whole) if whole not in (None, "") else 0
|
|
207
|
+
if frac not in (None, ""):
|
|
208
|
+
value += int(frac) / 1e6
|
|
209
|
+
return value
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def text_of(fields: dict[str, str]) -> str:
|
|
213
|
+
"""Altium writes either `Text` or, for non-ANSI content, `%UTF8%Text`."""
|
|
214
|
+
return (fields.get("%UTF8%Text") or fields.get("Text", "")).strip()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def contains_part(path: Path | str, part: str) -> bool:
|
|
218
|
+
"""Cheap pre-filter: does this sheet mention the part at all?
|
|
219
|
+
|
|
220
|
+
The MCU is drawn on only a couple of sheets in a real design. Scanning the raw bytes is
|
|
221
|
+
far cheaper than parsing every sheet, and it avoids hardcoding sheet names.
|
|
222
|
+
"""
|
|
223
|
+
try:
|
|
224
|
+
with open(path, "rb") as fh:
|
|
225
|
+
return part.encode("latin-1") in fh.read()
|
|
226
|
+
except OSError:
|
|
227
|
+
return False
|