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,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
@@ -0,0 +1,5 @@
1
+ """Firmware source indexing."""
2
+
3
+ from pcba.parsers.firmware.index import index, scan_file
4
+
5
+ __all__ = ["index", "scan_file"]