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,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
|
+
}
|