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,250 @@
1
+ """Index pin and peripheral references in firmware C sources.
2
+
3
+ Deliberately shallow: regex plus structure, not a C parser. It only has to answer one
4
+ question — *does the firmware name a pin that the .ioc disagrees about?* — and a full
5
+ translation unit parse would buy nothing for that.
6
+
7
+ The circularity trap
8
+ --------------------
9
+ CubeMX generates `main.h` **from** the `.ioc`:
10
+
11
+ #define USART3_TX_Pin GPIO_PIN_8
12
+ #define USART3_TX_GPIO_Port GPIOD
13
+
14
+ So checking those defines against the `.ioc` is vacuous — they agree by construction, and a
15
+ rule built on them would report a permanent, meaningless pass. It would *look* like
16
+ firmware/config validation while validating nothing.
17
+
18
+ What is NOT circular is a **hardcoded** pin reference: hand-written code that says
19
+ `GPIOD, GPIO_PIN_8` directly instead of using the generated define. That code does not move
20
+ when the pin moves in CubeMX. It is the actual field failure — the pinout changes, the
21
+ generated header follows, and the one USER CODE block that spelled the pin out by hand keeps
22
+ driving the old pad. So this indexer separates generated references from hand-written ones,
23
+ and only the hand-written ones feed the cross-domain rule.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import re
30
+ from pathlib import Path
31
+
32
+ from pcba.model.pinmap import FirmwareIndex, FirmwareReference
33
+
34
+ SOURCE_SUFFIXES = (".c", ".h", ".cpp", ".hpp", ".cc")
35
+
36
+ # Directories that hold vendor code we did not write and must not judge.
37
+ SKIP_DIRECTORIES = frozenset(
38
+ {
39
+ "Drivers",
40
+ "Middlewares",
41
+ "Third_Party",
42
+ "ThirdParty",
43
+ "build",
44
+ "Debug",
45
+ "Release",
46
+ "cmake-build-debug",
47
+ ".git",
48
+ "node_modules",
49
+ "CMSIS",
50
+ }
51
+ )
52
+
53
+ # CubeMX's generated pin defines. Their presence marks a file as generated-ish and their
54
+ # content is, by construction, whatever the .ioc says.
55
+ _GENERATED_DEFINE = re.compile(r"^\s*#define\s+(\w+)_(?:Pin|GPIO_Port)\s+", re.MULTILINE)
56
+
57
+ # HAL handle naming convention: `huart3` -> USART3/UART3, `hfdcan1` -> FDCAN1.
58
+ _HANDLE = re.compile(r"\bh(adc|dma|fdcan|i2c|spi|tim|uart|usart|sdmmc|eth|rtc|iwdg|wwdg)(\d+)\b")
59
+
60
+ # USER CODE blocks survive regeneration — code inside them is hand-written by definition.
61
+ _USER_CODE_BEGIN = re.compile(r"/\*\s*USER CODE BEGIN")
62
+ _USER_CODE_END = re.compile(r"/\*\s*USER CODE END")
63
+
64
+
65
+ def _uncommented_lines(text: str) -> list[str]:
66
+ """Blank comments and literals while retaining offsets and line numbers.
67
+
68
+ Lines are split on `\n` only. `str.splitlines()` also splits on form feeds and Unicode
69
+ separators, which may sit inside a blanked comment, and the two line lists would drift.
70
+ A string or character literal ends at an unescaped newline: C does not allow a literal to
71
+ span lines, and an apostrophe in `#error Can't build` must not hide the rest of the file.
72
+ """
73
+ chars = list(text)
74
+ state = "code"
75
+ i = 0
76
+ while i < len(text):
77
+ pair = text[i:i + 2]
78
+ c = text[i]
79
+ if state == "code":
80
+ if pair in ("//", "/*"):
81
+ state = "line" if pair == "//" else "block"
82
+ chars[i:i + 2] = [" ", " "]
83
+ i += 2
84
+ continue
85
+ if c in ('"', "'"):
86
+ state = c
87
+ chars[i] = " "
88
+ elif state == "line":
89
+ if c == "\n":
90
+ state = "code"
91
+ else:
92
+ chars[i] = " "
93
+ elif state == "block":
94
+ if pair == "*/":
95
+ chars[i:i + 2] = [" ", " "]
96
+ state = "code"
97
+ i += 2
98
+ continue
99
+ if c != "\n":
100
+ chars[i] = " "
101
+ else:
102
+ if c == "\\" and i + 1 < len(text):
103
+ chars[i] = " "
104
+ if text[i + 1] != "\n":
105
+ chars[i + 1] = " "
106
+ i += 2
107
+ continue
108
+ if c == state or c == "\n":
109
+ state = "code"
110
+ if c != "\n":
111
+ chars[i] = " "
112
+ i += 1
113
+ return "".join(chars).split("\n")
114
+
115
+
116
+ def _is_generated(text: str) -> bool:
117
+ """Does CubeMX own this file outside its USER CODE blocks?
118
+
119
+ Two signs, either one sufficient. Generated pin defines mark `main.h`. USER CODE markers
120
+ mark every other file CubeMX writes (`main.c`, `gpio.c`, `*_hal_msp.c`, `*_it.c`): they
121
+ are how CubeMX knows what to keep when it regenerates the rest from the `.ioc`. Code
122
+ outside them is CubeMX's own, and an MSP DeInit that names `GPIOD, GPIO_PIN_8` for an
123
+ unlabelled USART pin agrees with the `.ioc` by construction. Counting it would report
124
+ every such pin as drift on every CubeMX project.
125
+ """
126
+ return len(_GENERATED_DEFINE.findall(text)) >= 2 or bool(_USER_CODE_BEGIN.search(text))
127
+
128
+
129
+ # `HAL_GPIO_WritePin(GPIOD, GPIO_PIN_8, ...)` -> port D, pin 8. The mask argument may span
130
+ # lines and combine pins; LL spells them `LL_GPIO_PIN_8`.
131
+ _PIN_CALL = re.compile(r"\b(\w+)\s*\(\s*GPIO([A-K])\s*,")
132
+ _PIN_MASK = re.compile(r"\b(?:LL_)?GPIO_PIN_(\d{1,2})\b")
133
+ # A port/pin pair outside a recognised call: pin tables and hand-written convenience
134
+ # defines such as `#define LED GPIOD, GPIO_PIN_8`.
135
+ _PIN_PAIR = re.compile(r"\bGPIO([A-K])\s*,\s*((?:LL_)?GPIO_PIN_\d{1,2}(?:\s*\|\s*(?:LL_)?GPIO_PIN_\d{1,2})*)\b")
136
+ _LITERAL_CONDITION = re.compile(r"#\s*(?:if|elif)\s+([01])")
137
+ _OPERATIONS = {
138
+ "HAL_GPIO_WritePin": "write", "HAL_GPIO_TogglePin": "write",
139
+ "HAL_GPIO_ReadPin": "read", "HAL_GPIO_LockPin": "configure", "HAL_GPIO_DeInit": "configure",
140
+ "LL_GPIO_SetOutputPin": "write", "LL_GPIO_ResetOutputPin": "write", "LL_GPIO_TogglePin": "write",
141
+ "LL_GPIO_IsInputPinSet": "read", "LL_GPIO_IsOutputPinSet": "read", "LL_GPIO_IsPinLocked": "read",
142
+ "LL_GPIO_GetPinMode": "read", "LL_GPIO_GetPinOutputType": "read", "LL_GPIO_GetPinSpeed": "read",
143
+ "LL_GPIO_GetPinPull": "read", "LL_GPIO_GetAFPin_0_7": "read", "LL_GPIO_GetAFPin_8_15": "read",
144
+ "LL_GPIO_SetPinMode": "configure", "LL_GPIO_SetPinOutputType": "configure",
145
+ "LL_GPIO_SetPinSpeed": "configure", "LL_GPIO_SetPinPull": "configure",
146
+ "LL_GPIO_SetAFPin_0_7": "configure", "LL_GPIO_SetAFPin_8_15": "configure",
147
+ "LL_GPIO_LockPin": "configure",
148
+ }
149
+
150
+
151
+ def _argument_end(code: str, start: int) -> int:
152
+ """Offset where the argument beginning at `start` ends: its own `,` or `)`, or `;`."""
153
+ depth = 0
154
+ for i in range(start, len(code)):
155
+ c = code[i]
156
+ if c in "([{":
157
+ depth += 1
158
+ elif c in ")]}":
159
+ if depth == 0:
160
+ return i
161
+ depth -= 1
162
+ elif (c == "," and depth == 0) or c == ";":
163
+ return i
164
+ return len(code)
165
+
166
+
167
+ def scan_file(path: Path) -> list[FirmwareReference]:
168
+ text = path.read_text(encoding="utf-8", errors="replace")
169
+ generated = _is_generated(text)
170
+ raw_lines = text.split("\n")
171
+ lines = _uncommented_lines(text)
172
+ user_code = False
173
+ # One entry per open conditional: [branch disabled, an earlier branch was taken].
174
+ # Only literal `0` and `1` conditions are resolved; anything else needs build context
175
+ # and is treated as live, so an unknown branch is never silently dropped.
176
+ stack: list[list[bool | None]] = []
177
+ for i, raw in enumerate(raw_lines):
178
+ if _USER_CODE_BEGIN.search(raw):
179
+ user_code = True
180
+ elif _USER_CODE_END.search(raw):
181
+ user_code = False
182
+ line = lines[i].strip()
183
+ literal = _LITERAL_CONDITION.fullmatch(line)
184
+ if re.match(r"#\s*(if|ifdef|ifndef)\b", line):
185
+ value = literal.group(1) == "1" if literal else None
186
+ stack.append([value is False, value])
187
+ elif re.match(r"#\s*endif\b", line) and stack:
188
+ stack.pop()
189
+ elif re.match(r"#\s*elif\b", line) and stack:
190
+ value = literal.group(1) == "1" if literal else None
191
+ taken = stack[-1][1]
192
+ stack[-1] = [taken is True or value is False, True if taken or value else taken]
193
+ elif re.match(r"#\s*else\b", line) and stack:
194
+ stack[-1] = [stack[-1][1] is True, True]
195
+ if any(entry[0] for entry in stack) or (generated and not user_code):
196
+ lines[i] = " " * len(lines[i])
197
+ code = "\n".join(lines)
198
+ where = str(path.absolute())
199
+ out: list[FirmwareReference] = []
200
+
201
+ def add(port: str, mask: str, start: int, end: int, operation: str) -> None:
202
+ first = code.count("\n", 0, start)
203
+ last = code.count("\n", 0, end)
204
+ text = " ".join(line.strip() for line in raw_lines[first:last + 1])[:240]
205
+ for pin in sorted(set(_PIN_MASK.findall(mask)), key=int):
206
+ out.append(FirmwareReference(
207
+ symbol=f"P{port}{int(pin)}", kind="pin", path=where, line=first + 1,
208
+ text=text, operation=operation,
209
+ ))
210
+
211
+ in_calls: set[int] = set()
212
+ for match in _PIN_CALL.finditer(code):
213
+ call, port = match.groups()
214
+ end = _argument_end(code, match.end())
215
+ in_calls.add(match.start(2) - len("GPIO"))
216
+ add(port, code[match.end():end], match.start(), end, _OPERATIONS.get(call, "unknown"))
217
+ for match in _PIN_PAIR.finditer(code):
218
+ if match.start() not in in_calls:
219
+ add(match.group(1), match.group(2), match.start(), match.end(), "unknown")
220
+ for match in _HANDLE.finditer(code):
221
+ number = code.count("\n", 0, match.start()) + 1
222
+ family, instance = match.groups()
223
+ out.append(FirmwareReference(
224
+ symbol=family.upper() + instance, kind="peripheral", path=where,
225
+ line=number, text=raw_lines[number - 1].strip()[:120],
226
+ ))
227
+ return out
228
+
229
+
230
+ def _sources(root: Path) -> list[Path]:
231
+ """Every C/C++ source under `root`. Vendor and build trees are pruned before they are
232
+ entered, as discovery prunes its walk, rather than enumerated and filtered afterwards."""
233
+ found: list[Path] = []
234
+ for current, directories, files in os.walk(root):
235
+ directories[:] = [name for name in directories if name not in SKIP_DIRECTORIES]
236
+ found.extend(
237
+ Path(current, name) for name in files if Path(name).suffix in SOURCE_SUFFIXES
238
+ )
239
+ # Sorted as paths, so references (and the first one a drift finding is located at) come
240
+ # out in the same order on every machine.
241
+ return sorted(path for path in found if path.is_file())
242
+
243
+
244
+ def index(root: Path) -> FirmwareIndex:
245
+ """Walk a firmware tree and index every pin/peripheral reference we can see."""
246
+ root = Path(root)
247
+ out = FirmwareIndex(root=str(root))
248
+ for path in _sources(root):
249
+ out.references.extend(scan_file(path))
250
+ return out
@@ -0,0 +1,49 @@
1
+ """STM32CubeMX `.ioc` and MCU-database reading.
2
+
3
+ Read-only by design. The `.ioc` is CubeMX-owned; any write path must go through a CubeMX
4
+ adapter and the change-plan workflow, never through this module.
5
+ """
6
+
7
+ from pcba.parsers.ioc.mcu_db import (
8
+ alternate_function_table,
9
+ expand_variants,
10
+ find_mcu_db,
11
+ part_peripherals,
12
+ pin_signals,
13
+ resolve_part_xml,
14
+ )
15
+ from pcba.parsers.ioc.reader import (
16
+ IocError,
17
+ clocks,
18
+ collect_pins,
19
+ diff,
20
+ is_pin_key,
21
+ load,
22
+ parse_ioc,
23
+ part_name,
24
+ peripherals,
25
+ pin_name,
26
+ )
27
+ from pcba.parsers.ioc.validate import check_can_timing, load_manifest, validate
28
+
29
+ __all__ = [
30
+ "IocError",
31
+ "alternate_function_table",
32
+ "check_can_timing",
33
+ "clocks",
34
+ "collect_pins",
35
+ "diff",
36
+ "expand_variants",
37
+ "find_mcu_db",
38
+ "is_pin_key",
39
+ "load",
40
+ "load_manifest",
41
+ "parse_ioc",
42
+ "part_name",
43
+ "part_peripherals",
44
+ "peripherals",
45
+ "pin_name",
46
+ "pin_signals",
47
+ "resolve_part_xml",
48
+ "validate",
49
+ ]
@@ -0,0 +1,180 @@
1
+ """STM32CubeMX's local MCU database — ST's own answer to "can this pin do that?".
2
+
3
+ CubeMX ships a per-MCU XML listing every pin and every signal it can carry. It is the
4
+ authority: never answer an alternate-function question from memory, and never from the
5
+ datasheet PDF either.
6
+
7
+ This reads CubeMX's *data*, never drives its *UI*, so it is safe from any shell.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import functools
13
+ import itertools
14
+ import os
15
+ import re
16
+ import xml.etree.ElementTree as ET
17
+ from pathlib import Path
18
+
19
+ from pcba.parsers.ioc.reader import IocError
20
+
21
+ MCU_DB_CANDIDATES = (
22
+ "/Applications/STMicroelectronics/STM32CubeMX.app/Contents/Resources/db/mcu",
23
+ "/opt/stm32cubemx/db/mcu",
24
+ "C:/Program Files/STMicroelectronics/STM32Cube/STM32CubeMX/db/mcu",
25
+ "C:/Program Files (x86)/STMicroelectronics/STM32Cube/STM32CubeMX/db/mcu",
26
+ str(Path.home() / "STM32CubeMX/db/mcu"),
27
+ str(Path.home() / "STM32Cube/STM32CubeMX/db/mcu"),
28
+ )
29
+
30
+
31
+ # Overrides the install locations above. CI and the test suite pin it, so a review does not
32
+ # depend on whether the machine running it happens to have CubeMX installed.
33
+ DB_ENVIRONMENT = "PCBA_CUBEMX_DB"
34
+
35
+
36
+ def find_mcu_db(explicit: Path | None = None) -> Path:
37
+ if explicit:
38
+ if not explicit.is_dir():
39
+ raise IocError(f"--db is not a directory: {explicit}")
40
+ return explicit
41
+ configured = os.environ.get(DB_ENVIRONMENT)
42
+ if configured:
43
+ path = Path(configured)
44
+ if not path.is_dir():
45
+ raise IocError(f"{DB_ENVIRONMENT} is not a directory: {configured}")
46
+ return path
47
+ for candidate in MCU_DB_CANDIDATES:
48
+ path = Path(candidate)
49
+ if path.is_dir():
50
+ return path
51
+ raise IocError(
52
+ "STM32CubeMX MCU database not found. Pass --db <dir> pointing at CubeMX's db/mcu."
53
+ )
54
+
55
+
56
+ def expand_variants(stem: str) -> set[str]:
57
+ """'STM32G0B1C(B-C-E)Tx' -> {STM32G0B1CBTx, STM32G0B1CCTx, STM32G0B1CETx}.
58
+
59
+ ST groups flash/package variants into one XML with a parenthesised alternation, because
60
+ the die is identical and only the marketing suffix differs. Resolving a part therefore
61
+ means expanding those groups, not pattern-matching around them.
62
+
63
+ The previous implementation globbed `f"{user[:9]}*{user[-3:]}*.xml"` for this, which
64
+ built `STM32G0B1*ETx*.xml` and matched nothing at all — the real filename contains
65
+ `E)Tx`, not `ETx`. It survived only because the exact-name branch shadowed it, so the
66
+ fallback was dead code that would fail the moment it was actually needed.
67
+ """
68
+ groups = re.findall(r"\(([^)]*)\)", stem)
69
+ if not groups:
70
+ return {stem}
71
+ literals = re.split(r"\([^)]*\)", stem)
72
+ out: set[str] = set()
73
+ for combo in itertools.product(*(g.split("-") for g in groups)):
74
+ pieces = [literals[0]]
75
+ for choice, literal in zip(combo, literals[1:]):
76
+ pieces.append(choice)
77
+ pieces.append(literal)
78
+ out.add("".join(pieces))
79
+ return out
80
+
81
+
82
+ @functools.lru_cache(maxsize=8)
83
+ def _variant_index(db: str) -> dict[str, str]:
84
+ """Every orderable part name -> the XML file that describes it."""
85
+ index: dict[str, str] = {}
86
+ for xml in Path(db).glob("STM32*.xml"):
87
+ for variant in expand_variants(xml.stem):
88
+ index.setdefault(variant, str(xml))
89
+ return index
90
+
91
+
92
+ def resolve_part_xml(db: Path, part: str | None, mcu_name: str | None = None) -> Path:
93
+ """Locate the XML for a part.
94
+
95
+ `Mcu.Name` in the `.ioc` is already the variant string (e.g. `STM32G0B1C(B-C-E)Tx`), so
96
+ it hits the file directly. `Mcu.UserName` is the orderable part (`STM32G0B1CETx`) and
97
+ needs the expanded index.
98
+ """
99
+ if not part:
100
+ raise IocError("the .ioc names no MCU part (neither Mcu.UserName nor Mcu.Name)")
101
+ if mcu_name:
102
+ exact = db / f"{mcu_name}.xml"
103
+ if exact.is_file():
104
+ return exact
105
+
106
+ index = _variant_index(str(db))
107
+ if part in index:
108
+ return Path(index[part])
109
+
110
+ # Temperature/quality grades (STM32H745IIT6 vs ...IITx) are the same die; ST's DB only
111
+ # carries the 'x' form. Try it before giving up.
112
+ relaxed = re.sub(r"[36]$", "x", part)
113
+ if relaxed in index:
114
+ return Path(index[relaxed])
115
+
116
+ raise IocError(f"no MCU XML found for {part!r} in {db}")
117
+
118
+
119
+ def _tag(root: ET.Element, name: str) -> str:
120
+ """`name` qualified with the document's default namespace, if it has one.
121
+
122
+ ST's files declare one, so the qualified form is what normally matches. A file without
123
+ it used to yield an empty pin table and an empty peripheral set — silently, which is
124
+ worse than an error: the alternate-function rule then skips while coverage reports it
125
+ as having run.
126
+ """
127
+ namespace = root.tag.split("}")[0].strip("{") if "}" in root.tag else ""
128
+ return f"{{{namespace}}}{name}" if namespace else name
129
+
130
+
131
+ def _parse_xml(xml_path: str) -> ET.Element:
132
+ """Parse one MCU XML, turning a corrupt file into a controlled failure.
133
+
134
+ `ET.ParseError` is a SyntaxError, which nothing on the review path catches; one damaged
135
+ file in CubeMX's database would take the whole review down with a traceback.
136
+ """
137
+ try:
138
+ return ET.parse(xml_path).getroot()
139
+ except ET.ParseError as exc:
140
+ raise IocError(f"cannot parse MCU database file {xml_path}: {exc}") from exc
141
+
142
+
143
+ @functools.lru_cache(maxsize=16)
144
+ def _pin_table(xml_path: str) -> dict[str, tuple[str, ...]]:
145
+ """pin -> every signal it can carry. Keyed on the bare pin name.
146
+
147
+ ST writes `PC14-OSC32_IN` and `PA11/PA9`; a caller asking about `PC14` must still hit.
148
+ """
149
+ root = _parse_xml(xml_path)
150
+ signal_tag = _tag(root, "Signal")
151
+ table: dict[str, tuple[str, ...]] = {}
152
+ for element in root.iter(_tag(root, "Pin")):
153
+ name = element.get("Name")
154
+ if not name:
155
+ continue
156
+ base = re.split(r"[-/(]", name)[0]
157
+ signals = tuple(
158
+ s.get("Name", "") for s in element.iter(signal_tag) if s.get("Name")
159
+ )
160
+ table[base] = signals
161
+ return table
162
+
163
+
164
+ def pin_signals(xml_path: Path, pin: str) -> tuple[str, ...]:
165
+ return _pin_table(str(xml_path)).get(pin.upper(), ())
166
+
167
+
168
+ def alternate_function_table(xml_path: Path) -> dict[str, set[str]]:
169
+ return {pin: set(signals) for pin, signals in _pin_table(str(xml_path)).items()}
170
+
171
+
172
+ @functools.lru_cache(maxsize=16)
173
+ def part_peripherals(xml_path: str) -> frozenset[str]:
174
+ """Every peripheral instance the silicon has (from the IP list)."""
175
+ root = _parse_xml(xml_path)
176
+ return frozenset(
177
+ element.get("InstanceName", "")
178
+ for element in root.iter(_tag(root, "IP"))
179
+ if element.get("InstanceName")
180
+ )
@@ -0,0 +1,182 @@
1
+ """Read STM32CubeMX `.ioc` files.
2
+
3
+ The `.ioc` is CubeMX-owned and must never be hand-edited — but reading it is the fastest way
4
+ to know what a project is actually configured to do, and to verify what a regeneration
5
+ changed.
6
+
7
+ stdlib only.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from pathlib import Path
14
+
15
+ from pcba.model.pinmap import McuConfig, PinAssignment
16
+
17
+ CLOCK_PREFIXES = ("RCC.", "PWR.")
18
+
19
+
20
+ class IocError(RuntimeError):
21
+ """A controlled failure while reading an `.ioc`."""
22
+
23
+
24
+ def parse_ioc(path: Path) -> dict[str, str]:
25
+ """Read an `.ioc` as a flat key -> value map.
26
+
27
+ The format is INI-like but flat: one `key=value` per line, no sections. Duplicate keys
28
+ mean the file is corrupt (CubeMX never emits them), so that is an error rather than
29
+ silently keeping the last one.
30
+
31
+ CubeMX writes Java properties, which are ISO-8859-1 by definition, while editors and
32
+ other tools save UTF-8, with or without a byte-order mark, and CRLF line endings. UTF-8
33
+ is tried first and Latin-1 decodes any byte, so an umlaut in a project name degrades to
34
+ a readable value rather than an internal error.
35
+ """
36
+ try:
37
+ data = path.read_bytes()
38
+ except OSError as exc:
39
+ raise IocError(f"cannot read {path}: {exc}") from exc
40
+ try:
41
+ text = data.decode("utf-8-sig")
42
+ except UnicodeDecodeError:
43
+ text = data.decode("latin-1")
44
+
45
+ values: dict[str, str] = {}
46
+ # Split on line breaks only: `str.splitlines()` also breaks on NEL (0x85) and other
47
+ # separators that a Latin-1 value may contain.
48
+ for number, raw in enumerate(re.split(r"\r\n|\r|\n", text), start=1):
49
+ line = raw.strip()
50
+ if not line or line.startswith("#"):
51
+ continue
52
+ if "=" not in line:
53
+ raise IocError(f"{path}:{number}: line without '='")
54
+ key, value = line.split("=", 1)
55
+ if key in values:
56
+ raise IocError(f"{path}:{number}: duplicate key {key}")
57
+ values[key] = value
58
+ return values
59
+
60
+
61
+ def normalise_pin(name: str) -> str:
62
+ r"""Undo CubeMX's escaping in pin keys.
63
+
64
+ CubeMX writes remapped and dual-function pins with escapes, e.g.
65
+ PA11\ [PA9].GPIO_Label
66
+ PC14-OSC32_IN\ (PC14).Mode
67
+ A parser that does not undo this silently fails to match 'PA11' or 'PC14'.
68
+ """
69
+ return name.replace("\\ ", " ").replace("\\(", "(").replace("\\)", ")")
70
+
71
+
72
+ def pin_name(key: str) -> str:
73
+ r"""'PA11\ [PA9].GPIO_Label' -> 'PA11'; 'PB5(JTDO/TRACESWO).Signal' -> 'PB5'."""
74
+ base = normalise_pin(key).split(".", 1)[0]
75
+ for separator in (" ", "-", "("):
76
+ if separator in base:
77
+ base = base.split(separator, 1)[0]
78
+ return base
79
+
80
+
81
+ # The guard that makes `pins` mean pins.
82
+ #
83
+ # The obvious filter is `key.startswith("P")`, and it is wrong: CubeMX's own metadata keys
84
+ # start with P too, so `ProjectManager.ProjectName` and `PinOutPanel.RotationAngle` get
85
+ # reported as pins of the MCU. An engineer who sees `ProjectManager` in a pin table stops
86
+ # believing the rest of the table, which is the whole product.
87
+ #
88
+ # Ports run A..K on most families; the STM32N6 skips L and M and continues with PN..PQ.
89
+ # Dedicated pins (OTG1_HSDP, CSI_D0P) do not look like ports at all; `collect_pins` takes
90
+ # them from the `.ioc`'s own `Mcu.PinN` list instead.
91
+ _PIN_KEY = re.compile(r"^P[A-KN-Q]\d{1,2}(_C)?([\\ ()\[\]\-].*)?$")
92
+
93
+
94
+ def listed_pins(values: dict[str, str]) -> frozenset[str]:
95
+ """Physical pins the `.ioc` itself lists under `Mcu.PinN`, virtual `VP_` pins excluded.
96
+
97
+ This is how dedicated pins such as OTG1_HSDP or CSI_D0P are recognised. Their names do
98
+ not look like port pins, so without the list a USB device configured on the internal
99
+ PHY reads as unassigned.
100
+ """
101
+ return frozenset(
102
+ normalise_pin(value) for key, value in values.items()
103
+ if re.fullmatch(r"Mcu\.Pin\d+", key) and not value.startswith("VP_")
104
+ )
105
+
106
+
107
+ def is_pin_key(key: str, listed: frozenset[str] = frozenset()) -> bool:
108
+ """Does this `.ioc` key describe a physical pin?"""
109
+ if "." not in key:
110
+ return False
111
+ base = normalise_pin(key).split(".", 1)[0]
112
+ return bool(_PIN_KEY.match(base)) or base in listed
113
+
114
+
115
+ def collect_pins(values: dict[str, str]) -> dict[str, dict[str, str]]:
116
+ """pin -> {Signal, GPIO_Label, Mode, ...}, only for pins that carry a signal."""
117
+ listed = listed_pins(values)
118
+ pins: dict[str, dict[str, str]] = {}
119
+ for key, value in values.items():
120
+ if not is_pin_key(key, listed):
121
+ continue
122
+ field = key.rsplit(".", 1)[1]
123
+ if field in ("GPIOParameters",):
124
+ continue
125
+ pins.setdefault(pin_name(key), {})[field] = value
126
+ return {pin: fields for pin, fields in pins.items() if fields}
127
+
128
+
129
+ def peripherals(values: dict[str, str]) -> list[str]:
130
+ """The `Mcu.IPn` list — the fastest way to see what is switched on."""
131
+ found = [
132
+ value for key, value in values.items() if key.startswith("Mcu.IP") and key[6:].isdigit()
133
+ ]
134
+ return sorted(found)
135
+
136
+
137
+ def clocks(values: dict[str, str]) -> dict[str, str]:
138
+ return {k: v for k, v in sorted(values.items()) if k.startswith(CLOCK_PREFIXES)}
139
+
140
+
141
+ def part_name(values: dict[str, str]) -> str | None:
142
+ """The orderable part (`Mcu.UserName`), else the variant name, else None.
143
+
144
+ None rather than a placeholder string: a truthy placeholder used to satisfy every
145
+ `if part:` guard downstream, so discovery skipped the schematic fallback and the
146
+ schematic extractor crashed trying to derive a part family from "(unknown part)".
147
+ """
148
+ return values.get("Mcu.UserName") or values.get("Mcu.Name") or None
149
+
150
+
151
+ def load(path: Path) -> McuConfig:
152
+ """Read an `.ioc` into the normalized model."""
153
+ values = parse_ioc(path)
154
+ listed = listed_pins(values)
155
+ signal_keys = {pin_name(key): key for key in values
156
+ if is_pin_key(key, listed) and key.endswith(".Signal")}
157
+ assignments = {
158
+ pin: PinAssignment(
159
+ pin=pin,
160
+ signal=fields.get("Signal"),
161
+ label=fields.get("GPIO_Label"),
162
+ mode=fields.get("Mode"),
163
+ signal_key=signal_keys.get(pin),
164
+ )
165
+ for pin, fields in collect_pins(values).items()
166
+ }
167
+ return McuConfig(
168
+ part=part_name(values),
169
+ source=str(path),
170
+ assignments=assignments,
171
+ peripherals=tuple(peripherals(values)),
172
+ raw=values,
173
+ )
174
+
175
+
176
+ def diff(before: dict[str, str], after: dict[str, str]) -> dict[str, list]:
177
+ """What a regeneration (or a GUI edit) actually changed."""
178
+ return {
179
+ "removed": sorted(set(before) - set(after)),
180
+ "added": sorted(set(after) - set(before)),
181
+ "changed": sorted(k for k in set(before) & set(after) if before[k] != after[k]),
182
+ }