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
pcba/components.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Component requirements: what a part needs from the circuit around it.
|
|
2
|
+
|
|
3
|
+
Data, not code. The shipped file `pcba/data/components/generic.json` holds generic, invented
|
|
4
|
+
entries (a 3.3 V MCU, an LDO, a 5 V logic gate, an I2C sensor) so the rules have something to
|
|
5
|
+
check against; a project adds its own files through `[components] databases` in
|
|
6
|
+
`pcba-requirements.toml`. The schema is `schemas/components.schema.json`:
|
|
7
|
+
|
|
8
|
+
{
|
|
9
|
+
"schema_version": 1,
|
|
10
|
+
"parts": [
|
|
11
|
+
{
|
|
12
|
+
"id": "generic-ldo-3v3",
|
|
13
|
+
"kind": "ic", # ic | capacitor | resistor | other
|
|
14
|
+
"description": "...",
|
|
15
|
+
"match": {"mpn": [...], "value": [...], "lib_id": ["regex", ...]},
|
|
16
|
+
"supplies": [{"pins": ["VIN"], "operating": [2.5, 16.0], "absolute": [-0.3, 18.0]}],
|
|
17
|
+
"grounds": ["GND"],
|
|
18
|
+
"inputs": [{"pins": ["EN"], "vil_max": 0.4, "vih_min": 1.2, "max": 18.0}],
|
|
19
|
+
"outputs": [{"pins": ["VOUT"], "voh_min": 3.2, "vol_max": 0.1, "kind": "push_pull"}],
|
|
20
|
+
"required": [{"pin": "EN", "requires": "not_floating"},
|
|
21
|
+
{"pin": "VOUT", "requires": "capacitor_to_ground", "value": "1u"}]
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
Pin names are regular expressions matched against the whole pin name. Voltages are volts;
|
|
27
|
+
input and output levels hold at the part's nominal supply. `requires` is one of
|
|
28
|
+
`capacitor_to_ground`, `not_floating`, `pull_up`, `pull_down`, `connected`. A part is matched
|
|
29
|
+
by `[components] assign` first, then by its MPN property, its value, and its library id.
|
|
30
|
+
A part with no entry is reported as "not checked: no component data", never as passing.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import dataclasses
|
|
36
|
+
import json
|
|
37
|
+
import re
|
|
38
|
+
from importlib import resources
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from pcba.model.design import Component
|
|
42
|
+
|
|
43
|
+
SCHEMA_VERSION = 1
|
|
44
|
+
REQUIRES = ("capacitor_to_ground", "not_floating", "pull_up", "pull_down", "connected")
|
|
45
|
+
MPN_FIELDS = ("MPN", "Manufacturer Part Number", "Manufacturer_Part_Number", "MPN#", "PartNumber", "Part Number")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ComponentDataError(ValueError):
|
|
49
|
+
"""A component database file is malformed."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
53
|
+
class Supply:
|
|
54
|
+
pins: tuple[str, ...]
|
|
55
|
+
operating: tuple[float, float]
|
|
56
|
+
absolute: tuple[float, float] | None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
60
|
+
class Level:
|
|
61
|
+
pins: tuple[str, ...]
|
|
62
|
+
low: float | None # vil_max for an input, vol_max for an output
|
|
63
|
+
high: float | None # vih_min for an input, voh_min for an output
|
|
64
|
+
limit: float | None = None # an input's absolute maximum voltage
|
|
65
|
+
kind: str = "push_pull" # outputs: push_pull | open_drain
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
69
|
+
class Required:
|
|
70
|
+
pin: str
|
|
71
|
+
requires: str
|
|
72
|
+
value: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
76
|
+
class Part:
|
|
77
|
+
id: str
|
|
78
|
+
kind: str
|
|
79
|
+
description: str
|
|
80
|
+
source: str
|
|
81
|
+
mpn: tuple[str, ...] = ()
|
|
82
|
+
values: tuple[str, ...] = ()
|
|
83
|
+
lib_ids: tuple[str, ...] = ()
|
|
84
|
+
supplies: tuple[Supply, ...] = ()
|
|
85
|
+
grounds: tuple[str, ...] = ()
|
|
86
|
+
inputs: tuple[Level, ...] = ()
|
|
87
|
+
outputs: tuple[Level, ...] = ()
|
|
88
|
+
required: tuple[Required, ...] = ()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def pin_matches(patterns: tuple[str, ...] | str, name: str) -> bool:
|
|
92
|
+
if isinstance(patterns, str):
|
|
93
|
+
patterns = (patterns,)
|
|
94
|
+
return any(re.fullmatch(p, name or "") for p in patterns)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _strings(value, where: str) -> tuple[str, ...]:
|
|
98
|
+
if value is None:
|
|
99
|
+
return ()
|
|
100
|
+
if isinstance(value, str):
|
|
101
|
+
value = [value]
|
|
102
|
+
if not isinstance(value, list) or any(not isinstance(v, str) or not v for v in value):
|
|
103
|
+
raise ComponentDataError(f"{where} must be a list of strings")
|
|
104
|
+
for pattern in value:
|
|
105
|
+
try:
|
|
106
|
+
re.compile(pattern)
|
|
107
|
+
except re.error as exc:
|
|
108
|
+
raise ComponentDataError(f"{where}: invalid pattern {pattern!r}: {exc}") from exc
|
|
109
|
+
return tuple(value)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _pair(value, where: str) -> tuple[float, float]:
|
|
113
|
+
if (not isinstance(value, list) or len(value) != 2
|
|
114
|
+
or any(isinstance(v, bool) or not isinstance(v, (int, float)) for v in value) or value[0] > value[1]):
|
|
115
|
+
raise ComponentDataError(f"{where} must be [low, high]")
|
|
116
|
+
return float(value[0]), float(value[1])
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _optional(table: dict, key: str, where: str) -> float | None:
|
|
120
|
+
value = table.get(key)
|
|
121
|
+
if value is None:
|
|
122
|
+
return None
|
|
123
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
124
|
+
raise ComponentDataError(f"{where}.{key} must be a number")
|
|
125
|
+
return float(value)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _keys(table: dict, allowed: set[str], where: str) -> None:
|
|
129
|
+
extra = sorted(set(table) - allowed)
|
|
130
|
+
if extra:
|
|
131
|
+
raise ComponentDataError(f"unknown fields in {where}: {', '.join(extra)}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse(payload: dict, source: str) -> list[Part]:
|
|
135
|
+
if not isinstance(payload, dict) or payload.get("schema_version") != SCHEMA_VERSION:
|
|
136
|
+
raise ComponentDataError(f"{source}: schema_version must be {SCHEMA_VERSION}")
|
|
137
|
+
_keys(payload, {"schema_version", "description", "parts"}, source)
|
|
138
|
+
parts = payload.get("parts")
|
|
139
|
+
if not isinstance(parts, list):
|
|
140
|
+
raise ComponentDataError(f"{source}: parts must be a list")
|
|
141
|
+
out = []
|
|
142
|
+
for index, raw in enumerate(parts):
|
|
143
|
+
where = f"{source} parts[{index}]"
|
|
144
|
+
if not isinstance(raw, dict):
|
|
145
|
+
raise ComponentDataError(f"{where} must be an object")
|
|
146
|
+
_keys(raw, {"id", "kind", "description", "match", "supplies", "grounds", "inputs", "outputs", "required"},
|
|
147
|
+
where)
|
|
148
|
+
if not isinstance(raw.get("id"), str) or not raw["id"]:
|
|
149
|
+
raise ComponentDataError(f"{where}.id is required")
|
|
150
|
+
kind = raw.get("kind", "ic")
|
|
151
|
+
if kind not in ("ic", "capacitor", "resistor", "other"):
|
|
152
|
+
raise ComponentDataError(f"{where}.kind must be ic, capacitor, resistor or other")
|
|
153
|
+
match = raw.get("match", {})
|
|
154
|
+
if not isinstance(match, dict):
|
|
155
|
+
raise ComponentDataError(f"{where}.match must be an object")
|
|
156
|
+
_keys(match, {"mpn", "value", "lib_id"}, f"{where}.match")
|
|
157
|
+
supplies = []
|
|
158
|
+
for i, s in enumerate(raw.get("supplies", [])):
|
|
159
|
+
w = f"{where}.supplies[{i}]"
|
|
160
|
+
_keys(s, {"pins", "operating", "absolute"}, w)
|
|
161
|
+
supplies.append(Supply(_strings(s.get("pins"), f"{w}.pins"), _pair(s.get("operating"), f"{w}.operating"),
|
|
162
|
+
_pair(s["absolute"], f"{w}.absolute") if "absolute" in s else None))
|
|
163
|
+
inputs = []
|
|
164
|
+
for i, s in enumerate(raw.get("inputs", [])):
|
|
165
|
+
w = f"{where}.inputs[{i}]"
|
|
166
|
+
_keys(s, {"pins", "vil_max", "vih_min", "max"}, w)
|
|
167
|
+
inputs.append(Level(_strings(s.get("pins"), f"{w}.pins"), _optional(s, "vil_max", w),
|
|
168
|
+
_optional(s, "vih_min", w), _optional(s, "max", w)))
|
|
169
|
+
outputs = []
|
|
170
|
+
for i, s in enumerate(raw.get("outputs", [])):
|
|
171
|
+
w = f"{where}.outputs[{i}]"
|
|
172
|
+
_keys(s, {"pins", "vol_max", "voh_min", "kind"}, w)
|
|
173
|
+
kind_out = s.get("kind", "push_pull")
|
|
174
|
+
if kind_out not in ("push_pull", "open_drain"):
|
|
175
|
+
raise ComponentDataError(f"{w}.kind must be push_pull or open_drain")
|
|
176
|
+
outputs.append(Level(_strings(s.get("pins"), f"{w}.pins"), _optional(s, "vol_max", w),
|
|
177
|
+
_optional(s, "voh_min", w), kind=kind_out))
|
|
178
|
+
required = []
|
|
179
|
+
for i, s in enumerate(raw.get("required", [])):
|
|
180
|
+
w = f"{where}.required[{i}]"
|
|
181
|
+
_keys(s, {"pin", "requires", "value"}, w)
|
|
182
|
+
if s.get("requires") not in REQUIRES:
|
|
183
|
+
raise ComponentDataError(f"{w}.requires must be one of {', '.join(REQUIRES)}")
|
|
184
|
+
_strings(s.get("pin"), f"{w}.pin")
|
|
185
|
+
required.append(Required(s["pin"], s["requires"], s.get("value")))
|
|
186
|
+
out.append(Part(
|
|
187
|
+
id=raw["id"], kind=kind, description=str(raw.get("description", "")), source=source,
|
|
188
|
+
mpn=_strings(match.get("mpn"), f"{where}.match.mpn"),
|
|
189
|
+
values=_strings(match.get("value"), f"{where}.match.value"),
|
|
190
|
+
lib_ids=_strings(match.get("lib_id"), f"{where}.match.lib_id"),
|
|
191
|
+
supplies=tuple(supplies), grounds=_strings(raw.get("grounds"), f"{where}.grounds"),
|
|
192
|
+
inputs=tuple(inputs), outputs=tuple(outputs), required=tuple(required),
|
|
193
|
+
))
|
|
194
|
+
return out
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclasses.dataclass(slots=True)
|
|
198
|
+
class Library:
|
|
199
|
+
parts: list[Part] = dataclasses.field(default_factory=list)
|
|
200
|
+
sources: list[str] = dataclasses.field(default_factory=list)
|
|
201
|
+
assign: dict[str, str] = dataclasses.field(default_factory=dict)
|
|
202
|
+
|
|
203
|
+
def by_id(self, part_id: str) -> Part | None:
|
|
204
|
+
# A project file loaded after the shipped one overrides an entry with the same id.
|
|
205
|
+
return next((p for p in reversed(self.parts) if p.id == part_id), None)
|
|
206
|
+
|
|
207
|
+
def match(self, component: Component) -> tuple[Part, str] | None:
|
|
208
|
+
"""(part, how it matched) for a placed component, or None."""
|
|
209
|
+
assigned = self.assign.get(component.ref)
|
|
210
|
+
if assigned:
|
|
211
|
+
part = self.by_id(assigned)
|
|
212
|
+
return (part, f"assigned to {assigned} in the requirements file") if part else None
|
|
213
|
+
mpn = next((component.properties[f] for f in MPN_FIELDS if component.properties.get(f)), None)
|
|
214
|
+
for part in reversed(self.parts):
|
|
215
|
+
if mpn and any(mpn.upper() == m.upper() for m in part.mpn):
|
|
216
|
+
return part, f"MPN {mpn}"
|
|
217
|
+
for part in reversed(self.parts):
|
|
218
|
+
if component.value and any(component.value.upper() == v.upper() for v in part.values):
|
|
219
|
+
return part, f"value {component.value}"
|
|
220
|
+
for part in reversed(self.parts):
|
|
221
|
+
if any(re.fullmatch(p, component.lib_id) for p in part.lib_ids):
|
|
222
|
+
return part, f"library symbol {component.lib_id}"
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def builtin_files() -> list[tuple[str, str]]:
|
|
227
|
+
"""(name, text) of the shipped component files."""
|
|
228
|
+
folder = resources.files("pcba") / "data" / "components"
|
|
229
|
+
return sorted((item.name, item.read_text(encoding="utf-8")) for item in folder.iterdir()
|
|
230
|
+
if item.name.endswith(".json"))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def load(extra: tuple[Path, ...] = (), assign: dict[str, str] | None = None) -> Library:
|
|
234
|
+
library = Library(assign=dict(assign or {}))
|
|
235
|
+
for name, text in builtin_files():
|
|
236
|
+
library.parts += parse(json.loads(text), f"pcba/data/components/{name}")
|
|
237
|
+
library.sources.append(f"pcba/data/components/{name}")
|
|
238
|
+
for path in extra:
|
|
239
|
+
try:
|
|
240
|
+
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
241
|
+
except (OSError, ValueError) as exc:
|
|
242
|
+
raise ComponentDataError(f"cannot read component data {path}: {exc}") from exc
|
|
243
|
+
library.parts += parse(payload, str(path))
|
|
244
|
+
library.sources.append(str(path))
|
|
245
|
+
unknown = sorted(ref for ref, part_id in library.assign.items() if library.by_id(part_id) is None)
|
|
246
|
+
if unknown:
|
|
247
|
+
raise ComponentDataError("components.assign names unknown ids for " + ", ".join(unknown))
|
|
248
|
+
return library
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# --- values ------------------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
_SI = {"p": 1e-12, "n": 1e-9, "u": 1e-6, "µ": 1e-6, "μ": 1e-6, "m": 1e-3, "k": 1e3, "K": 1e3, "M": 1e6,
|
|
254
|
+
"G": 1e9, "R": 1.0, "r": 1.0, "": 1.0}
|
|
255
|
+
_VALUE = re.compile(r"^\s*(\d+(?:[.,]\d+)?)\s*([pnuµμmkKMGRr]?)(\d*)\s*(?:F|Ω|ohm|Ohm|H)?\b", re.U)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def parse_value(text: str | None) -> float | None:
|
|
259
|
+
"""`100n` -> 1e-7, `4k7` -> 4700, `0R1` -> 0.1, `2.2uF` -> 2.2e-6, `10k 1%` -> 10000."""
|
|
260
|
+
if not text:
|
|
261
|
+
return None
|
|
262
|
+
match = _VALUE.match(text.replace(",", "."))
|
|
263
|
+
if not match:
|
|
264
|
+
return None
|
|
265
|
+
whole, prefix, decimals = match.groups()
|
|
266
|
+
number = float(f"{whole}.{decimals}") if decimals and "." not in whole else float(whole)
|
|
267
|
+
return number * _SI[prefix]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
_RATING = re.compile(r"(?<![\w.])(\d+(?:[.,]\d+)?)\s*V(?:DC)?\b", re.I)
|
|
271
|
+
_POWER = re.compile(r"(?<![\w.])(?:(\d+)/(\d+)|(\d+(?:[.,]\d+)?)\s*(m?))\s*W\b")
|
|
272
|
+
VOLTAGE_FIELDS = ("Voltage", "Rated Voltage", "Voltage Rating", "VoltageRating", "Rating", "V")
|
|
273
|
+
POWER_FIELDS = ("Power", "Power Rating", "PowerRating", "Rated Power")
|
|
274
|
+
DIELECTRIC_FIELDS = ("Dielectric", "Type", "Technology", "Material")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def rated_voltage(component: Component) -> tuple[float, str] | None:
|
|
278
|
+
"""(rating in volts, where it was read) from a voltage field or the value text."""
|
|
279
|
+
for field in VOLTAGE_FIELDS:
|
|
280
|
+
text = component.properties.get(field)
|
|
281
|
+
if text:
|
|
282
|
+
match = _RATING.search(text) or re.fullmatch(r"\s*(\d+(?:[.,]\d+)?)\s*", text)
|
|
283
|
+
if match:
|
|
284
|
+
return float(match.group(1).replace(",", ".")), f"{field} = {text!r}"
|
|
285
|
+
match = _RATING.search(component.value or "")
|
|
286
|
+
if match:
|
|
287
|
+
return float(match.group(1).replace(",", ".")), f"value {component.value!r}"
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def rated_power(component: Component) -> tuple[float, str] | None:
|
|
292
|
+
for field in POWER_FIELDS:
|
|
293
|
+
text = component.properties.get(field)
|
|
294
|
+
if text:
|
|
295
|
+
match = _POWER.search(text)
|
|
296
|
+
if match:
|
|
297
|
+
return _watts(match), f"{field} = {text!r}"
|
|
298
|
+
match = _POWER.search(component.value or "")
|
|
299
|
+
if match:
|
|
300
|
+
return _watts(match), f"value {component.value!r}"
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _watts(match: re.Match) -> float:
|
|
305
|
+
if match.group(1):
|
|
306
|
+
return float(match.group(1)) / float(match.group(2))
|
|
307
|
+
value = float(match.group(3).replace(",", "."))
|
|
308
|
+
return value / 1000 if match.group(4) else value
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def capacitor_class(component: Component) -> str:
|
|
312
|
+
"""ceramic | electrolytic | tantalum | film | polymer, from fields, value and symbol."""
|
|
313
|
+
text = " ".join([component.value, component.lib_id, component.footprint]
|
|
314
|
+
+ [component.properties.get(f, "") for f in DIELECTRIC_FIELDS]).upper()
|
|
315
|
+
if "TANT" in text:
|
|
316
|
+
return "tantalum"
|
|
317
|
+
if "POLYMER" in text:
|
|
318
|
+
return "polymer"
|
|
319
|
+
if re.search(r"ELEC|C_POLARIZED|CP_ELEC|\bCP\b|:CP\b|CPOL|ALU", text):
|
|
320
|
+
return "electrolytic"
|
|
321
|
+
if re.search(r"\bFILM\b|MKP|MKT|X2|Y2", text):
|
|
322
|
+
return "film"
|
|
323
|
+
return "ceramic"
|
pcba/config.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Optional project configuration for repositories with more than one review target."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
CONFIG_NAME = "pcba-studio.toml"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProjectConfigError(ValueError):
|
|
14
|
+
"""The optional project configuration is malformed or ambiguous."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
18
|
+
class Target:
|
|
19
|
+
name: str
|
|
20
|
+
part: str | None = None
|
|
21
|
+
schematic_component: str | None = None
|
|
22
|
+
ioc: str | None = None
|
|
23
|
+
schematic_dir: str | None = None
|
|
24
|
+
firmware_roots: tuple[str, ...] | None = None
|
|
25
|
+
kicad_project: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
29
|
+
class ProjectConfig:
|
|
30
|
+
path: Path
|
|
31
|
+
default_target: str | None
|
|
32
|
+
targets: dict[str, Target]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
36
|
+
class SelectedTarget:
|
|
37
|
+
name: str
|
|
38
|
+
part: str | None
|
|
39
|
+
schematic_component: str | None
|
|
40
|
+
ioc: Path | None
|
|
41
|
+
schematic_dir: Path | None
|
|
42
|
+
firmware_roots: tuple[Path, ...] | None
|
|
43
|
+
kicad_project: Path | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _optional_string(table: dict[str, Any], key: str, target: str) -> str | None:
|
|
47
|
+
value = table.get(key)
|
|
48
|
+
if value is None:
|
|
49
|
+
return None
|
|
50
|
+
if not isinstance(value, str) or not value.strip():
|
|
51
|
+
raise ProjectConfigError(f"targets.{target}.{key} must be a non-empty string")
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load(root: Path | str) -> ProjectConfig | None:
|
|
56
|
+
path = Path(root).resolve() / CONFIG_NAME
|
|
57
|
+
if not path.exists():
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
payload = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
61
|
+
except OSError as exc:
|
|
62
|
+
raise ProjectConfigError(f"cannot read {path}: {exc}") from exc
|
|
63
|
+
except UnicodeDecodeError as exc:
|
|
64
|
+
raise ProjectConfigError(f"{path} is not UTF-8: {exc}") from exc
|
|
65
|
+
except tomllib.TOMLDecodeError as exc:
|
|
66
|
+
raise ProjectConfigError(f"invalid TOML in {path}: {exc}") from exc
|
|
67
|
+
|
|
68
|
+
unknown = sorted(set(payload) - {"version", "default_target", "targets"})
|
|
69
|
+
if unknown:
|
|
70
|
+
raise ProjectConfigError(f"unknown project config fields: {', '.join(unknown)}")
|
|
71
|
+
if payload.get("version") != 1:
|
|
72
|
+
raise ProjectConfigError("pcba-studio.toml version must be 1")
|
|
73
|
+
default = payload.get("default_target")
|
|
74
|
+
if default is not None and (not isinstance(default, str) or not default.strip()):
|
|
75
|
+
raise ProjectConfigError("default_target must be a non-empty string")
|
|
76
|
+
raw_targets = payload.get("targets")
|
|
77
|
+
if not isinstance(raw_targets, dict) or not raw_targets:
|
|
78
|
+
raise ProjectConfigError("pcba-studio.toml must define at least one [targets.NAME]")
|
|
79
|
+
|
|
80
|
+
targets: dict[str, Target] = {}
|
|
81
|
+
allowed = {"part", "ioc", "schematic_dir", "firmware_roots", "schematic_component", "kicad_project"}
|
|
82
|
+
for name, raw in raw_targets.items():
|
|
83
|
+
if not isinstance(name, str) or not name.strip() or not isinstance(raw, dict):
|
|
84
|
+
raise ProjectConfigError("every target must be a named TOML table")
|
|
85
|
+
unknown = sorted(set(raw) - allowed)
|
|
86
|
+
if unknown:
|
|
87
|
+
raise ProjectConfigError(f"unknown fields in target {name}: {', '.join(unknown)}")
|
|
88
|
+
roots = raw.get("firmware_roots")
|
|
89
|
+
if roots is not None and (
|
|
90
|
+
not isinstance(roots, list)
|
|
91
|
+
or any(not isinstance(value, str) or not value.strip() for value in roots)
|
|
92
|
+
):
|
|
93
|
+
raise ProjectConfigError(f"targets.{name}.firmware_roots must be a string array")
|
|
94
|
+
targets[name] = Target(
|
|
95
|
+
name=name,
|
|
96
|
+
part=_optional_string(raw, "part", name),
|
|
97
|
+
schematic_component=_optional_string(raw, "schematic_component", name),
|
|
98
|
+
ioc=_optional_string(raw, "ioc", name),
|
|
99
|
+
schematic_dir=_optional_string(raw, "schematic_dir", name),
|
|
100
|
+
firmware_roots=tuple(roots) if roots is not None else None,
|
|
101
|
+
kicad_project=_optional_string(raw, "kicad_project", name),
|
|
102
|
+
)
|
|
103
|
+
if default is not None and default not in targets:
|
|
104
|
+
raise ProjectConfigError(f"default_target {default!r} is not defined")
|
|
105
|
+
return ProjectConfig(path=path, default_target=default, targets=targets)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _resolve(root: Path, value: str, label: str) -> Path:
|
|
109
|
+
candidate = (root / value).resolve()
|
|
110
|
+
try:
|
|
111
|
+
candidate.relative_to(root)
|
|
112
|
+
except ValueError as exc:
|
|
113
|
+
raise ProjectConfigError(f"{label} must stay inside the project: {value}") from exc
|
|
114
|
+
return candidate
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def select(root: Path | str, name: str | None = None) -> SelectedTarget | None:
|
|
118
|
+
project_root = Path(root).resolve()
|
|
119
|
+
config = load(project_root)
|
|
120
|
+
if config is None:
|
|
121
|
+
if name:
|
|
122
|
+
raise ProjectConfigError(f"--target requires {CONFIG_NAME} in {project_root}")
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
selected_name = name or config.default_target
|
|
126
|
+
if selected_name is None:
|
|
127
|
+
if len(config.targets) == 1:
|
|
128
|
+
selected_name = next(iter(config.targets))
|
|
129
|
+
else:
|
|
130
|
+
available = ", ".join(sorted(config.targets))
|
|
131
|
+
raise ProjectConfigError(
|
|
132
|
+
f"several targets are configured ({available}); pass --target or set default_target"
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
target = config.targets[selected_name]
|
|
136
|
+
except KeyError as exc:
|
|
137
|
+
available = ", ".join(sorted(config.targets))
|
|
138
|
+
raise ProjectConfigError(
|
|
139
|
+
f"unknown target {selected_name!r}; available targets: {available}"
|
|
140
|
+
) from exc
|
|
141
|
+
|
|
142
|
+
ioc = _resolve(project_root, target.ioc, f"targets.{selected_name}.ioc") if target.ioc else None
|
|
143
|
+
schematic = (
|
|
144
|
+
_resolve(project_root, target.schematic_dir, f"targets.{selected_name}.schematic_dir")
|
|
145
|
+
if target.schematic_dir
|
|
146
|
+
else None
|
|
147
|
+
)
|
|
148
|
+
firmware = (
|
|
149
|
+
tuple(
|
|
150
|
+
_resolve(project_root, value, f"targets.{selected_name}.firmware_roots")
|
|
151
|
+
for value in target.firmware_roots
|
|
152
|
+
)
|
|
153
|
+
if target.firmware_roots is not None
|
|
154
|
+
else None
|
|
155
|
+
)
|
|
156
|
+
kicad_project = (
|
|
157
|
+
_resolve(project_root, target.kicad_project, f"targets.{selected_name}.kicad_project")
|
|
158
|
+
if target.kicad_project
|
|
159
|
+
else None
|
|
160
|
+
)
|
|
161
|
+
if kicad_project and not kicad_project.exists():
|
|
162
|
+
raise ProjectConfigError(f"configured KiCad project does not exist: {kicad_project}")
|
|
163
|
+
if ioc and not ioc.is_file():
|
|
164
|
+
raise ProjectConfigError(f"configured .ioc does not exist: {ioc}")
|
|
165
|
+
if schematic and not schematic.is_dir():
|
|
166
|
+
raise ProjectConfigError(f"configured schematic directory does not exist: {schematic}")
|
|
167
|
+
for firmware_root in firmware or ():
|
|
168
|
+
if not firmware_root.is_dir() or not (firmware_root / "Core" / "Src").is_dir():
|
|
169
|
+
raise ProjectConfigError(
|
|
170
|
+
f"configured firmware root has no Core/Src directory: {firmware_root}"
|
|
171
|
+
)
|
|
172
|
+
return SelectedTarget(
|
|
173
|
+
name=selected_name,
|
|
174
|
+
part=target.part,
|
|
175
|
+
schematic_component=target.schematic_component,
|
|
176
|
+
ioc=ioc,
|
|
177
|
+
schematic_dir=schematic,
|
|
178
|
+
firmware_roots=firmware,
|
|
179
|
+
kicad_project=kicad_project,
|
|
180
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"description": "Generic, invented component requirements shipped with EngineHall. They stand for typical parts, not for any manufacturer's product: use them as examples and assign them explicitly ([components] assign in pcba-requirements.toml). Add real parts in a project file with the same schema.",
|
|
4
|
+
"parts": [
|
|
5
|
+
{
|
|
6
|
+
"id": "generic-mcu-3v3",
|
|
7
|
+
"kind": "ic",
|
|
8
|
+
"description": "Generic 3.3 V microcontroller: 1.7 to 3.6 V supply, CMOS I/O, reset pin needing a 100 nF capacitor.",
|
|
9
|
+
"match": {"value": ["GENERIC_MCU_3V3"]},
|
|
10
|
+
"supplies": [{"pins": ["VDD[A-Z0-9_]*", "VDDA"], "operating": [1.7, 3.6], "absolute": [-0.3, 4.0]}],
|
|
11
|
+
"grounds": ["VSS[A-Z0-9_]*", "GND"],
|
|
12
|
+
"inputs": [{"pins": ["P[A-Z]\\d{1,2}.*"], "vil_max": 0.99, "vih_min": 2.31, "max": 4.0}],
|
|
13
|
+
"outputs": [{"pins": ["P[A-Z]\\d{1,2}.*"], "vol_max": 0.4, "voh_min": 2.9, "kind": "push_pull"}],
|
|
14
|
+
"required": [
|
|
15
|
+
{"pin": "NRST", "requires": "capacitor_to_ground", "value": "100n"},
|
|
16
|
+
{"pin": "VDD[A-Z0-9_]*", "requires": "capacitor_to_ground"}
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "generic-ldo-3v3",
|
|
21
|
+
"kind": "ic",
|
|
22
|
+
"description": "Generic 3.3 V low-dropout regulator: 2.5 to 16 V input, enable input that must not float.",
|
|
23
|
+
"match": {"value": ["GENERIC_LDO_3V3"]},
|
|
24
|
+
"supplies": [{"pins": ["VIN"], "operating": [2.5, 16.0], "absolute": [-0.3, 18.0]}],
|
|
25
|
+
"grounds": ["GND"],
|
|
26
|
+
"inputs": [{"pins": ["EN"], "vil_max": 0.4, "vih_min": 1.2, "max": 18.0}],
|
|
27
|
+
"outputs": [{"pins": ["VOUT"], "vol_max": 0.1, "voh_min": 3.2, "kind": "push_pull"}],
|
|
28
|
+
"required": [
|
|
29
|
+
{"pin": "EN", "requires": "not_floating"},
|
|
30
|
+
{"pin": "VIN", "requires": "capacitor_to_ground"},
|
|
31
|
+
{"pin": "VOUT", "requires": "capacitor_to_ground", "value": "1u"}
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": "generic-logic-5v",
|
|
36
|
+
"kind": "ic",
|
|
37
|
+
"description": "Generic 5 V logic buffer: 4.5 to 5.5 V supply, TTL-level inputs, 5 V push-pull outputs.",
|
|
38
|
+
"match": {"value": ["GENERIC_BUF_5V"]},
|
|
39
|
+
"supplies": [{"pins": ["VCC"], "operating": [4.5, 5.5], "absolute": [-0.5, 7.0]}],
|
|
40
|
+
"grounds": ["GND"],
|
|
41
|
+
"inputs": [{"pins": ["A\\d*"], "vil_max": 0.8, "vih_min": 2.0, "max": 7.0}],
|
|
42
|
+
"outputs": [{"pins": ["Y\\d*"], "vol_max": 0.4, "voh_min": 4.4, "kind": "push_pull"}],
|
|
43
|
+
"required": [{"pin": "VCC", "requires": "capacitor_to_ground"}]
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"id": "generic-i2c-sensor-3v3",
|
|
47
|
+
"kind": "ic",
|
|
48
|
+
"description": "Generic I2C sensor: 1.8 to 3.6 V supply, open-drain SDA, inputs not 5 V tolerant.",
|
|
49
|
+
"match": {"value": ["GENERIC_I2C_SENSOR"]},
|
|
50
|
+
"supplies": [{"pins": ["VDD"], "operating": [1.8, 3.6], "absolute": [-0.3, 4.0]}],
|
|
51
|
+
"grounds": ["GND"],
|
|
52
|
+
"inputs": [{"pins": ["SCL", "SDA"], "vil_max": 0.54, "vih_min": 1.26, "max": 4.0}],
|
|
53
|
+
"outputs": [{"pins": ["SDA"], "vol_max": 0.4, "kind": "open_drain"}],
|
|
54
|
+
"required": [
|
|
55
|
+
{"pin": "SDA", "requires": "pull_up"},
|
|
56
|
+
{"pin": "SCL", "requires": "pull_up"},
|
|
57
|
+
{"pin": "VDD", "requires": "capacitor_to_ground"}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|