diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index bc62a95..4c83f9a 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -35,6 +35,22 @@ jobs: make clone-sources-arm ext/stmicro/header/ ext/stmicro/cubehal/ + # These archive repositories are private since copyright prevents republishing! + - name: Clone STMicro HTML Archive for the board pinouts + uses: actions/checkout@v4 + with: + repository: modm-ext/archive-stmicro-html + path: ext/stmicro/html-archive + ssh-key: ${{ secrets.SSH_KEY_STMICRO_HTML }} + + - name: Clone STMicro CubeMX Archive for the board pinouts + uses: actions/checkout@v4 + with: + repository: modm-ext/archive-stmicro-cubemx + path: ext/stmicro/cubemx + ssh-key: ${{ secrets.SSH_KEY_STMICRO_CUBEMX }} + + - name: Clone modm-ext/data.modm.dev repository if: startsWith(github.ref, 'refs/heads/main') uses: actions/checkout@v4 @@ -49,6 +65,15 @@ jobs: make build-homepage + - name: Upload Homepage for Review + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: homepage + path: docs/data.modm.dev/docs + retention-days: 3 + + - name: Push New Docs to Github if: startsWith(github.ref, 'refs/heads/main') run: | diff --git a/docs/.gitignore b/docs/.gitignore index 905ad09..135b578 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -3,6 +3,7 @@ src/api/ src/pinout/ src/svd/ src/variants/ +src/boards/ src/index.md src/source/overview.md src/pipeline/overview.md diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index d95e8f7..4b8af23 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -92,3 +92,4 @@ nav: - Pinouts: pinout/index.html - SVD Explorer: svd/index.html - Peripheral Variants: variants/index.html + - NUCLEO Pinouts: boards/index.html diff --git a/src/modm_data/html2pinout/__init__.py b/src/modm_data/html2pinout/__init__.py new file mode 100644 index 0000000..258b70e --- /dev/null +++ b/src/modm_data/html2pinout/__init__.py @@ -0,0 +1,334 @@ +# Copyright 2026, Niklas Hauser +# SPDX-License-Identifier: MPL-2.0 + +""" +# HTML to Board Pinout Pipeline + +Extracts the connector pinouts (ST morpho, ARDUINO, Zio) of the NUCLEO boards +from the connector tables of their user manuals. +""" + +import re +import logging +from collections import defaultdict +from ..html.document import Document +from .transcribed import BRIDGES, TRANSCRIBED + +__all__ = ["board_pinouts_from_user_manual", "board_bridges_from_user_manual", "inherit_bridge_defaults"] + +LOGGER = logging.getLogger(__name__) + +_TABLES = r"morpho|arduino|zio" +_NUMBER = re.compile(r"^(zio|connector)?pin(number|no\.?|nbr)?$") +_CONNECTOR = re.compile(r"^(connector|cn)$") +_MCU = re.compile(r"(stm32|mcu|stlink).*(pin?|port|name)$|^port$") +_GPIO = re.compile(r"(? str: + if cell is None: + return "" + text = re.sub(r"<[^>]+>", "", cell.text(sup="", br=" ")) + return re.sub(r"\s+", " ", re.sub(r"\(\d+\)", "", text)).strip() + + +def _key(text: str) -> str: + return text.replace(" ", "").lower() + + +def _signal(text: str) -> tuple[str, str]: + """:return: the normalized signal name and its kind: gpio, power, ground, system, nc or other.""" + if gpio := _GPIO.search(text): + return gpio.group(0), "gpio" + # Footnote leftovers and line breaks inside of names + text = re.sub(r" ?_ ?", "_", re.sub(r" ?\d\)$", "", text.upper())) + for pattern, name in _SIGNALS: + if match := re.fullmatch(pattern, text): + text = match.expand(name) + break + return text, next((kind for kind, pattern in _KINDS.items() if re.fullmatch(pattern, text)), "other") + + +def _field(header: str) -> str: + """:return: the normalized name of a table column.""" + if _MCU.search(_key(header)): + return "pin" + return next((name for name, pattern in _FIELDS.items() if re.search(pattern, _key(header))), "label") + + +def _boards(html: str) -> set[str]: + return set(_BOARD.findall(html)) + + +def _document_boards(chapters: list) -> set[str]: + # The title, features and ordering information name the boards of the manual, later chapters also name others + boards = set().union(*(_boards(c._path.read_text()) for c in chapters if c.number <= 2)) + return boards or set().union(*(_boards(c._path.read_text()) for c in chapters)) + + +def _groups(header: list[str]) -> dict[int, list[int]]: + """ + Tables place several connector pins side by side in one row. Every pin + number column owns the columns describing it, which are either repeated + (`Pin | Name | Pin | Name`) or mirrored (`Name | Pin | Pin | Name`). + """ + numbers = [x for x, h in enumerate(header) if _NUMBER.match(_key(h))] + if not numbers: + return {} + groups = {n: [] for n in numbers} + groups[numbers[0]] += range(0, numbers[0]) + groups[numbers[-1]] += range(numbers[-1] + 1, len(header)) + for a, b in zip(numbers, numbers[1:]): + between = list(range(a + 1, b)) + if between and b + 1 < len(header) and header[a + 1] == header[b + 1]: + groups[a] += between + elif between and a > 0 and header[a - 1] == header[b - 1]: + groups[b] += between + else: + half = len(between) // 2 + groups[a] += between[:half] + groups[b] += between[len(between) - half :] + return groups + + +def _table_pinout(table, kind: str) -> dict[str, dict]: + if not table._hrows: + return {} + hy = table._hrows - 1 + header = [_text(table.cell(x, hy)) for x in range(table.columns)] + rows = range(table._hrows, table.rows) + if not (groups := _groups(header)): + # Some tables only name the connectors in the header, then the pin numbers are found by their content + digits = [sum(_text(table.cell(x, y)).isdigit() for y in rows) for x in range(table.columns)] + hy, header = table._hrows, ["Pin" if d > len(rows) * 0.8 else "Pin name" for d in digits] + groups = _groups(header) + captions = set(_CN.findall(table.caption())) + pinout = defaultdict(dict) + unnamed = 1 + + for number, columns in groups.items(): + # The connector is named in the header above, in its own column or in the caption + above = " ".join(_text(table.cell(number, y)) for y in range(hy)) + connector = next(iter(_CN.findall(above)), next(iter(captions), "") if len(captions) == 1 else "") + connector_columns = [ + c + for c in columns + if _CONNECTOR.match(_key(header[c])) or any(_CN.match(_text(table.cell(c, y))) for y in rows) + ] + # The dedicated STM32 pin column is more trustworthy than free-form function columns + columns = sorted(set(columns) - set(connector_columns), key=lambda c: (not _MCU.search(_key(header[c])), c)) + + for y in rows: + if not (pin := _text(table.cell(number, y))).isdigit(): + continue + for c in connector_columns: + if text := _text(table.cell(c, y)): + connector = next(iter(_CN.findall(text)), text) + fields = defaultdict(list) + for c in sorted(columns): + if text := _text(table.cell(c, y)): + fields[_field(header[c])].append(text) + signals = [_signal(_text(table.cell(c, y))) for c in columns] + mcu = [s for c, s in zip(columns, signals) if _MCU.search(_key(header[c]))] + # ponytail: only the first GPIO is the signal, solder bridge alternatives stay in the pin field + # Without a GPIO in the STM32 pin column, only a supply or system signal can be on the pin, + # everything else is just the name of the unconnected connector pin. + signal = next((s for s in mcu or signals if s[1] == "gpio"), None) + signal = signal or next((s for s in signals if s[1] in {"power", "ground", "system"}), None) + signal = signal or next((s for s in signals if s[1] == "other" and not mcu), ("NC", "nc")) + # Unnamed connectors are numbered in table order, a repeated pin number starts the next one + if not connector and int(pin) in pinout[f"{kind}{unnamed}"]: + unnamed += 1 + pinout[connector or f"{kind}{unnamed}"][int(pin)] = { + "signal": signal[0], + "kind": signal[1], + **{name: " / ".join(texts) for name, texts in fields.items()}, + } + + # Tables without any GPIO describe something else than a pinout + if not any(p["kind"] == "gpio" for pins in pinout.values() for p in pins.values()): + return {} + return {c: pins for c, pins in pinout.items() if pins} + + +def board_pinouts_from_user_manual(document: Document) -> dict[str, dict[str, dict]]: + """ + :return: `board -> connector -> {type, side, pins: number -> {signal, kind, ...}}` for all NUCLEO boards in + the user manual. The signal names are normalized, the original table cells are kept as `pin` (STM32 + pin), `label` (connector pin name), `net`, `function`, `remark` and `bridge`. + """ + chapters = sorted(document.chapters(), key=lambda c: c.number) + boards = _document_boards(chapters) + pinouts = defaultdict(dict) + + for chapter in chapters: + for table in chapter.tables(): + # Not all tables have a caption, but then their heading describes them + kind = re.search(_TABLES, table.caption(), re.IGNORECASE) or re.search( + _TABLES, table.heading(), re.IGNORECASE + ) + if not kind or not (pinout := _table_pinout(table, kind.group(0).lower())): + continue + # Some manuals describe several boards with one table for each + for board in _boards(table.caption()) or boards: + for connector, pins in pinout.items(): + # A mostly different pinout is another connector with a wrong caption + known = pinouts[board].get(connector, {"pins": {}})["pins"] + if sum(n in known and known[n]["signal"] != p["signal"] for n, p in pins.items()) > len(pins) / 2: + LOGGER.warning(f"{document.fullname}: {board} {connector} is redefined in '{table.caption()}'") + connector += "-2" + current = pinouts[board].setdefault(connector, {"type": kind.group(0).lower(), "pins": {}})["pins"] + for number, pin in pins.items(): + if number in current and current[number]["signal"] != pin["signal"]: + LOGGER.warning( + f"{document.fullname}: {board} {connector}-{number} conflict: " + f"{current[number]['signal']} vs {pin['signal']} in '{table.caption()}'" + ) + continue + current[number] = pin + + # Some pinouts are only in a figure or got lost in the conversion to HTML + source, transcribed = TRANSCRIBED.get(document.name, ("", {})) + for connectors in pinouts.values(): + for connector, names in transcribed.items(): + pins = connectors.setdefault(connector, {"type": "morpho", "pins": {}})["pins"] + for number, name in enumerate(names.split(), start=1): + signal, kind = _signal(name) + pins.setdefault(number, {"signal": signal, "kind": kind, "label": name, "remark": f"From {source}"}) + + for connectors in pinouts.values(): + for connector in connectors.values(): + connector["pins"] = dict(sorted(connector["pins"].items())) + # The manuals list the left morpho connector first + morphos = [c for c in connectors.values() if c["type"] == "morpho"] + if len(morphos) == 2: + morphos[0]["side"], morphos[1]["side"] = "left", "right" + return dict(sorted(pinouts.items())) + + +def board_bridges_from_user_manual(document: Document) -> dict[str, list[dict]]: + """ + Solder bridges and jumpers change what a pin is connected to. The manuals describe their effect only as + text, so the bridges are linked to the GPIOs that their descriptions mention. + + :return: `board -> [{ids, name, source, gpios, pins, states: [{state, default, text}]}]` for all NUCLEO boards in + the user manual. The default state is printed in bold. `pins` maps a single bridge of a group to + its GPIOs, where the manual says so. + """ + chapters = sorted(document.chapters(), key=lambda c: c.number) + boards = _document_boards(chapters) + bridges = defaultdict(dict) + + for chapter in chapters: + for table in chapter.tables(r"solder bridge|SB configuration"): + if not table._hrows: + continue + columns, rows = range(table.columns), range(table._hrows, table.rows) + header = [_key(_text(table.cell(x, table._hrows - 1))) for x in columns] + # The headers are ambiguous, so the bridge column is the first one that names bridges in most rows. + # Columns to its right may list even more of them, for example the mutually exclusive ones. + named = [sum(bool(_BRIDGE.search(_text(table.cell(x, y)))) for y in rows) for x in columns] + state = next((x for x in columns if re.search(r"state|setting|status|value|position", header[x])), None) + if not max(named) or state is None: + continue + bridge = next(x for x in columns if named[x] >= max(named) / 2) + others = [x for x in columns if x not in {bridge, state}] + + for y in rows: + cell = _text(table.cell(bridge, y)) + if not (ids := tuple(dict.fromkeys(_BRIDGE.findall(cell)))): + continue + texts = list(dict.fromkeys(t for x in others if (t := _text(table.cell(x, y))))) + # Brackets behind the bridges either name them all or list the GPIOs of each one. Without + # a name there, it is in the definition column left of the bridges. + notes = re.findall(r"\(([^()]*)\)", cell) + name = " / ".join(dict.fromkeys(n.strip() for n in notes if _GPIO.sub("", n).strip(" /,"))) + name = name or next((_text(table.cell(x, y)) for x in others if x < bridge), "") + pins = {i: _GPIO.findall(n) for i, n in re.findall(r"((?:SB|JP)\d+)\s*\(([^()]*)\)", cell)} + text = " ".join(t for t in texts if t != name) + # The pins of the ST-LINK microcontroller are not the pins of the board + gpios = _GPIO.findall(_STLINK.sub("", f"{cell} {name} {text}")) + for board in _boards(table.caption()) or boards: + # Boards made of two PCBs number their bridges twice, so the table is part of the identity + entry = bridges[board].setdefault( + (table.caption(), ids), + {"ids": list(ids), "name": name, "source": _text(table._caption), "gpios": [], "states": []}, + ) + entry.setdefault("pins", {}).update({i: g for i, g in pins.items() if g}) + entry["gpios"] = sorted(set(entry["gpios"]) | set(gpios)) + option = { + "state": _text(table.cell(state, y)), + "default": "" in table.cell(state, y).html, + "text": text, + } + # Some tables list a bridge once for every feature it affects + same = next((o for o in entry["states"] if o["state"] == option["state"]), None) + if same is None: + entry["states"].append(option) + else: + same["default"] |= option["default"] + if text not in same["text"]: + same["text"] += " " + text + + # A bridge with only one described state is delivered in it + for entries in bridges.values(): + for entry in entries.values(): + if len(entry["states"]) == 1: + entry["states"][0]["default"] = True + + return { + board: list(bridges[board].values()) + BRIDGES.get(board, []) + for board in sorted(boards | bridges.keys()) + if board in bridges or board in BRIDGES + } + + +def inherit_bridge_defaults(bridges: dict[str, list[dict]], older: dict[str, list[dict]], source: str): + """ + Newer revisions of a manual sometimes lose the bold formatting of the default state. The older revision + still has it, so bridges without a default take the one of the same bridge there. + """ + for board, entries in bridges.items(): + known = {tuple(b["ids"]): b for b in older.get(board, [])} + for bridge in entries: + if any(o["default"] for o in bridge["states"]) or not (old := known.get(tuple(bridge["ids"]))): + continue + defaults = {o["state"] for o in old["states"] if o["default"]} + if len(defaults) == 1 and defaults & {o["state"] for o in bridge["states"]}: + bridge["default_from"] = source + for option in bridge["states"]: + option["default"] = option["state"] in defaults diff --git a/src/modm_data/html2pinout/__main__.py b/src/modm_data/html2pinout/__main__.py new file mode 100644 index 0000000..5abf93e --- /dev/null +++ b/src/modm_data/html2pinout/__main__.py @@ -0,0 +1,92 @@ +# Copyright 2026, Niklas Hauser +# SPDX-License-Identifier: MPL-2.0 + +import json +import logging +import argparse +from pathlib import Path + +from modm_data.html.document import Document +from modm_data.html2pinout import ( + board_bridges_from_user_manual, + board_pinouts_from_user_manual, + inherit_bridge_defaults, +) +from modm_data.html2pinout.signals import board_uses, device_for_board, device_signals, has_board_file +from modm_data.utils import ext_path + + +def _version(path: Path) -> tuple[int, ...]: + return tuple(int(v) for v in path.name.split("-v")[1].split("_")) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--document", type=Path, help="A single user manual, otherwise all are converted.") + parser.add_argument("--input", type=Path, default=ext_path("stmicro/html-archive")) + parser.add_argument("--output", type=Path, default=ext_path("stmicro/pinout/nucleo.json")) + args = parser.parse_args() + logging.basicConfig(level=logging.WARNING, format="%(message)s") + + if args.document: + paths = [args.document] + else: + # Always choose the latest version + latest = {} + for path in args.input.glob("UM*-v*"): + name = path.name.split("-v")[0] + if name not in latest or _version(path) > _version(latest[name]): + latest[name] = path + paths = sorted(latest.values()) + + pinouts = {} + for path in paths: + document = Document(path.absolute()) + for board, connectors in board_pinouts_from_user_manual(document).items(): + pins = sum(len(c["pins"]) for c in connectors.values()) + # Several manuals may name a board, the one that describes the most of it is its own + if board in pinouts and pins <= sum(len(c["pins"]) for c in pinouts[board]["connectors"].values()): + continue + pinouts[board] = {"board": board, "document": document.fullname, "connectors": connectors} + bridges = board_bridges_from_user_manual(document) + # Older revisions of the manual may still know the defaults that this one lost + older = sorted(args.input.glob(f"{document.name}-v*"), key=_version, reverse=True) + for old in older if not args.document else []: + missing = any(not any(o["default"] for o in b["states"]) for bs in bridges.values() for b in bs) + if missing and old != path: + inherit_bridge_defaults(bridges, board_bridges_from_user_manual(Document(old.absolute())), old.name) + for board, bridges in bridges.items(): + if pinouts.get(board, {}).get("document") == document.fullname: + pinouts[board]["bridges"] = bridges + + boards = [] + for board, data in sorted(pinouts.items()): + # Family names like NUCLEO-WB55 or NUCLEO-WL55JC describe the same board as NUCLEO-WB55RG or + # NUCLEO-WL55JC1. A real board like NUCLEO-L452RE next to NUCLEO-L452RE-P has its own description. + if any(other != board and other.startswith(board) for other in pinouts) and not has_board_file(board): + continue + # Only the signals of the GPIOs on the connectors are of interest + gpios = {p["signal"] for c in data["connectors"].values() for p in c["pins"].values() if p["kind"] == "gpio"} + device = device_for_board(board) + signals = device_signals(device[1]) if device else {} + data["device"] = device[0] if device else None + data["signals"] = {gpio: signals[gpio] for gpio in sorted(gpios) if gpio in signals} + # Only the bridges that mention a GPIO on the connectors + bridges = [dict(b, gpios=[g for g in b["gpios"] if g in gpios]) for b in data.pop("bridges", [])] + data["bridges"] = [b for b in bridges if b["gpios"]] + data["uses"] = {gpio: use for gpio, use in board_uses(board).items() if gpio in gpios} + boards.append(data) + sizes = ", ".join(f"{c}={len(p['pins'])}" for c, p in data["connectors"].items() if p["type"] == "morpho") + print(f"{board:20} {data['document']:12} {data['device'] or '-':16} {sizes}") + + if not boards: + print(f"No user manuals with board pinouts found in '{args.input}'!") + return False + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps({"boards": boards}, separators=(",", ":")) + "\n") + return True + + +if __name__ == "__main__": + exit(0 if main() else 1) diff --git a/src/modm_data/html2pinout/signals.py b/src/modm_data/html2pinout/signals.py new file mode 100644 index 0000000..65e5a39 --- /dev/null +++ b/src/modm_data/html2pinout/signals.py @@ -0,0 +1,124 @@ +# Copyright 2026, Niklas Hauser +# SPDX-License-Identifier: MPL-2.0 + +""" +The alternate and additional functions of the GPIOs of the STM32 device +mounted on a board and what the board itself already uses them for, taken from +the CubeMX database. +""" + +import re +from functools import cache +from pathlib import Path +from xml.etree import ElementTree +from ..utils import ext_path + +_NS = "{http://mcd.rou.st.com/modules.php?name=mcu}" + + +# What the GPIO labels of the board description say about their use on the board +_USES = { + "debug": r"SW(DIO|CLK|O|0)|JT|^T(MS|CK|DI|DO)$|TRACE|DEBUG", + "vcp": r"VCP|STLK|STLINK|^(LP)?US?ART\d*_[TR]X$", + "ethernet": r"RMII?_|^ETH", + "usb": r"USB|UCPD|VBUS", + "clock": r"OSC|MCO", + "led": r"^LD\d|LED", + "button": r"BUTTON|BTN|^B\d|^USER", +} + + +def _cubemx_path() -> Path: + return ext_path("stmicro/cubemx") + + +def _mcu_path() -> Path: + return _cubemx_path() / "mcu" + + +@cache +def _families() -> list[tuple[str, str]]: + root = ElementTree.parse(_mcu_path() / "families.xml").getroot() + return sorted((mcu.get("RefName"), mcu.get("Name")) for mcu in root.iter("Mcu")) + + +def device_for_board(board: str) -> tuple[str, str] | None: + """ + :param board: A board name like `NUCLEO-L452RE-P`. + :return: The name of the mounted device and of its CubeMX description, or `None` if unknown. + """ + if not (match := re.fullmatch(r"NUCLEO-(\w+?)(?:-([PQ]))?", board)): + return None + smps = match.group(2) or "" + # Some board names end in a revision digit, which is not part of the device name + for part in ("STM32" + match.group(1), "STM32" + match.group(1)[:-1]): + devices = [d for d in _families() if re.fullmatch(rf"{part}[A-Z]x{smps}?", d[0])] + # The boards prefer the LQFP package and the SMPS variant may have a suffix + order = [lambda d: d[0] == f"{part}Tx{smps}", lambda d: d[0] == f"{part}Tx", lambda d: True] + if device := next((d for check in order for d in devices if check(d)), None): + return device + return None + + +@cache +def device_signals(name: str) -> dict[str, dict[str, int | None]]: + """ + :param name: The name of a CubeMX device description like `STM32F401R(D-E)Tx`. + :return: `gpio -> signal -> alternate function number` or `None` for additional functions. + """ + mcu = ElementTree.parse(_mcu_path() / f"{name}.xml").getroot() + version = mcu.find(f"{_NS}IP[@Name='GPIO']").get("Version") + gpio = ElementTree.parse(_mcu_path() / f"IP/GPIO-{version}_Modes.xml").getroot() + ns = gpio.tag[: gpio.tag.index("}") + 1] + afs = {} + for pin in gpio.iter(f"{ns}GPIO_Pin"): + for signal in pin.iter(f"{ns}PinSignal"): + value = signal.findtext(f"{ns}SpecificParameter[@Name='GPIO_AF']/{ns}PossibleValue") or "" + if af := re.match(r"GPIO_AF(\d+)", value): + afs[(pin.get("Name"), signal.get("Name"))] = int(af.group(1)) + + signals = {} + for pin in mcu.iter(f"{_NS}Pin"): + # Pins have names like PA0-WKUP, PC14-OSC32_IN or PA9 [PA11] + if not (port := re.match(r"P[A-Z]\d+", pin.get("Name"))): + continue + for signal in pin.iter(f"{_NS}Signal"): + if (signal := signal.get("Name")) != "GPIO": + signals.setdefault(port.group(0), {})[signal] = afs.get((pin.get("Name"), signal)) + return {gpio: dict(sorted(names.items())) for gpio, names in signals.items()} + + +def _board_files(board: str) -> list[Path]: + return sorted((_cubemx_path() / "plugins/boardmanager/boards").glob(f"*_Nucleo_{board}_*_Board_AllConfig.ioc")) + + +def has_board_file(board: str) -> bool: + """:return: whether CubeMX describes a board with exactly this name.""" + return bool(_board_files(board)) + + +def board_uses(board: str) -> dict[str, dict[str, str]]: + """ + :param board: A board name like `NUCLEO-F429ZI`. + :return: `gpio -> {use, label, part, signal}` for all GPIOs that are connected to something on the board + itself, like the ST-LINK, an Ethernet PHY, a crystal, LEDs or buttons. + """ + if not (paths := _board_files(board)): + return {} + pins = {} + # The shortest name is the default configuration without TrustZone or multi-core variants + for line in min(paths, key=lambda p: len(p.name)).read_text(errors="replace").splitlines(): + if match := re.match(r"(P[A-Z]\d+)[^.=]*\.(GPIO_Label|Signal)=(.*)", line): + pins.setdefault(match.group(1), {})[match.group(2)] = match.group(3).strip() + + uses = {} + for gpio, pin in pins.items(): + label, _, part = pin.get("GPIO_Label", "").partition("[") + label, part, signal = label.strip(), part.strip(" ]"), pin.get("Signal", "") + name = (label or signal).upper() + use = next((use for use, pattern in _USES.items() if re.search(pattern, name)), "other") + # Neither the labels of the ARDUINO connector pins nor a default peripheral configuration occupy the pin + if re.match(r"ARD", label) or (use == "other" and label in {"", signal}): + continue + uses[gpio] = {k: v for k, v in {"use": use, "label": label, "part": part, "signal": signal}.items() if v} + return dict(sorted(uses.items())) diff --git a/src/modm_data/html2pinout/transcribed.py b/src/modm_data/html2pinout/transcribed.py new file mode 100644 index 0000000..3e0761b --- /dev/null +++ b/src/modm_data/html2pinout/transcribed.py @@ -0,0 +1,224 @@ +# Copyright 2026, Niklas Hauser +# SPDX-License-Identifier: MPL-2.0 + +""" +Pinouts that are missing in the HTML of the user manuals and were transcribed +by hand from their PDFs: either the manual only shows them as a figure, or the +first rows of a table got lost on a page break. + +The names are copied as printed, including what looks like mistakes of the +manual: UM2953 has PD2 on CN7-3 and CN7-4, PB12 on CN10-14 and CN10-16 and PB0 +on CN10-17 and CN10-34. +""" + +# document -> (source, connector -> signal names starting at pin 1), with the left morpho connector first. +TRANSCRIBED: dict[str, tuple[str, dict[str, str]]] = { + "UM2324": ( + "Figure 17 for NUCLEO-G070RB", + { + "CN7": "PC10 PC11 PC12 PD2 VDD E5V PA14 GND PD0 PD1 PD3 IOREF PA13 NRST PD4 +3V3 PA15 +5V GND GND " + "PB7 GND PC13 VIN PC14 PD5 PC15 PA0 PF0 PA1 PF1 PA4 VBAT PB1 PC2 PB11/PB9 PC3 PB12/PB8", + "CN10": "PC9 PC8 PB8 PC6 PB9 PA3 AVDD 5V-USB-CHG GND PD6 PA5 PA12 PA6 PA11 PA7 PC1 PB0 PC0 PC7 GND " + "PA9 PB2 PA8 PB6 PB14 PB15 PB4 PB10 PB5 PB13 PB3 AGND PA10 PA2 PC4 PD8 PC5 PD9", + }, + ), + "UM2206": ( + "Figure 18", + { + "CN5": "PC10 PC11 PC12 PD2 VDD E5V BOOT0 GND NC NC NC IOREF PB12 NRST PA13 3V3 PA14 5V GND GND " + "NC GND PC13 VIN PC14 NC PC15 PA0 PH0 PA1 PH1 PC3 VBAT PC2 PB4 PC1 PB9 PC0", + "CN6": "PC9 PC8 PB8 PC6 PB7 PC5 AVDD 5V-STLINK GND PB0 PB13 PA10 PB14 PA9 PB15 PB11 PA11 PB2 PA8 GND " + "PB6 PB1 PC7 PA7 PB10 PA6 PA15 PA5 PB5 PA4 PB3 AGND PA12 PC4 PA2/PA9 PA3 PA3/PA10 PA2", + }, + ), + "UM2953": ( + "Figure 7", + { + "CN7": "PD0 PD1 PD2 PD2 VDD E5V PA14 GND NC NC NC IOREF PA13 NRST NC +3V3 PC6 +5V GND GND " + "NC GND PC13 VIN PC14 NC NC PA0 PF0 PA1 PF1 PA4 NC PB1 PB11 PA11/PB9 PA2 PA12/PB8", + "CN10": "PD3 NC PB8 NC PB9 NC AVDD 5V-USB-CHG GND PA3 PA5 PC15 PA6 PB12 PA7 PB12 PB0 PB2 PC7 GND " + "PA9 PF3 PA15 PA8 PB5 PB15 PB4 PB14 PB10 PB13 PB3 AGND PA10 PB0 PB6 NC PB7 NC", + }, + ), + "UM2592": ( + "Table 18 in the PDF", + { + "CN7": "NC NC NC NC VDD_MCU E5V BOOT0 GND NC NC NC IOREF", + "CN10": "PA0 PC4 PA12 PC5 PA11 NC AVDD 5V_USB_CHGR GND NC PA5 PC6", + }, + ), + "UM2581": ( + "Table 21 in the PDF", + { + "CN11": "PC10 PC11", + "CN12": "PC9 PC8", + }, + ), +} + + +def _bridge(ids: str, name: str, gpios: str, *states: tuple[str, bool, str]) -> dict: + states = [{"state": state, "default": default, "text": text} for state, default, text in states] + return {"ids": ids.split(), "name": name, "gpios": gpios.split(), "pins": {}, "states": states} + + +_MORPHO_H5 = "the ARDUINO Uno V3 (D1 and D0) and ST morpho connectors (CN10 pins 35 and 37, and CN10 pins 26 and 28)" + +# board -> solder bridges that the manuals only describe in feature tables instead of a solder bridge table. +# The default is only set where the manual says so. +BRIDGES: dict[str, list[dict]] = { + # UM3121 Table 13. USART configuration + "NUCLEO-H503RB": [ + _bridge( + "SB2 SB3 SB18 SB22 SB19 SB33 SB20 SB23", + "USART configuration", + "PA3 PA4 PB14 PB15", + ( + "SB2, SB3 ON; SB18, SB22 OFF; SB19, SB33 ON; SB20, SB23 OFF", + True, + "USART3 (PA3/PA4) connected to the STLINK-V3EC Virtual COM port (USART3 supports the Bootloader " + f"mode). USART1 (PB14/PB15) connected to {_MORPHO_H5}.", + ), + ( + "SB2, SB3 OFF; SB18, SB22 ON; SB19, SB33 OFF; SB20, SB23 ON", + False, + "USART1 (PB14/PB15) connected to STLINK-V3EC Virtual COM port. " + f"USART3 (PA3/PA4) connected to {_MORPHO_H5}.", + ), + ), + ], + "NUCLEO-H533RE": [ + _bridge( + "SB1 SB2 SB3 SB7 SB18 SB22 SB19 SB33 SB20 SB23", + "USART configuration", + "PA2 PA3 PB14 PB15", + ( + "SB1, SB2 ON; SB3, SB7, SB18, SB22 OFF; SB19, SB33 ON; SB20, SB23 OFF", + True, + "USART2 (PA2/PA3) connected to the STLINK-V3EC Virtual COM port (USART2 supports the Bootloader " + f"mode). USART1 (PB14/PB15) connected to {_MORPHO_H5}.", + ), + ( + "SB1, SB2, SB3, SB22 OFF; SB7, SB18 ON; SB19, SB33 OFF; SB20, SB23 ON", + False, + "USART1 (PB14/PB15) connected to STLINK-V3EC Virtual COM port. " + f"USART2 (PA2/PA3) connected to {_MORPHO_H5}.", + ), + ), + ], + # UM2819 Table 7. MB1355 I/O configuration for the physical user interface, and the VCP bridge configuration + "NUCLEO-WB55RG": [ + _bridge( + "SB47 SB48", + "SW1", + "PC4 PC13", + ("SB47 ON, SB48 OFF", True, "SW1 is connected to PC4, no wake-up available."), + ("SB47 OFF, SB48 ON", False, "SW1 is connected to PC13, wake-up WKUP2 available."), + ), + _bridge( + "SB39", + "ST-LINK VCP", + "PB6", + ("JP5[13-14] ON, SB39 ON", False, "USART1_TX (PB6) connected to ST-LINK VCP_RX."), + ), + _bridge( + "SB38", + "ST-LINK VCP", + "PB7", + ("JP5[15-16] ON, SB38 ON", False, "USART1_RX (PB7) connected to ST-LINK VCP_TX."), + ), + ], + # UM3301 Table 8. VCP2 interface pinout description + "NUCLEO-WBA55CG": [ + _bridge( + "SB7", + "VCP2", + "PA10", + ("On MB1801: SB7 ON", False, "LPUART1_RX (PA10) on CN4 pin 37 (GPIO55) connected to STLINK_TX."), + ), + _bridge( + "SB8", + "VCP2", + "PB5", + ("On MB1801: SB8 ON", False, "LPUART1_TX (PB5) on CN4 pin 35 (GPIO54) connected to STLINK_RX."), + ), + _bridge( + "SB25 SB8 SB9 SB32 SB31", + "VCP2", + "PA0 PB15", + ( + "On MB1801: SB25 ON. On MB1803: SB8 ON and SB9 OFF (PA0) or SB32 ON and SB31 OFF (PB15)", + False, + "LPUART1_CTS (PA0 or PB15) on CN4 pin 16 (GPIO38) or CN4 pin 26 (GPIO46) connected to STLINK_RTS.", + ), + ), + _bridge( + "SB23 SB25", + "VCP2", + "PB9", + ( + "On MB1801: SB23 ON. On MB1803: SB25 ON and SB23 OFF", + False, + "LPUART1_RTS (PB9) on CN3 pin 2 (GPIO2) connected to STLINK_CTS.", + ), + ), + ], + # UM3448 Table 6 and 7. VCP1 and VCP2 interface pinout description + "NUCLEO-WBA65RI": [ + _bridge("SB5", "VCP1", "PA8", ("SB5 ON", False, "USART1 Rx (PA8) on pin 35 (GPIO23) connected to STLINK_TX.")), + _bridge( + "SB3", "VCP1", "PB12", ("SB3 ON", False, "USART1 Tx (PB12) on pin 37 (GPIO24) connected to STLINK_RX.") + ), + _bridge( + "SB16 SB13", + "VCP2", + "PA12", + ("SB16 ON, SB13 OFF", True, "USART2_TX (PA12) on CN4 pin 35 (GPIO54) connected to STLINK_RX."), + ), + _bridge( + "SB4 SB7", + "VCP2", + "PA15", + ("SB4 ON, SB7 OFF", True, "USART2_RTS (PA15) on CN3 pin 2 (GPIO2) connected to STLINK_CTS."), + ), + ], + # UM3610 Table 5 and 6. VCP1 and VCP2 interface pinout description + "NUCLEO-WBA25CE1": [ + _bridge( + "SB5", + "VCP1", + "PA12", + ("SB5 ON", False, "USART1 Rx (PA12) on CN3 pin 35 (GPIO23) connected to STLK_VCP_TX."), + ), + _bridge( + "SB21 SB3", + "VCP1", + "PA6", + ("SB21 ON, SB3 ON", False, "USART1 Tx (PA6) on CN3 pin 37 (GPIO24) connected to STLK_VCP_RX."), + ), + _bridge( + "SB10 SB4 SB7", + "VCP2", + "PA1", + ("SB10 ON, SB4 OFF; SB7 ON", True, "LPUART1_RX (PA1) on CN4 pin 37 (GPIO55) connected to T_VCP2_TX."), + ), + _bridge( + "SB8 SB43", + "VCP2", + "PH3", + ("SB8 ON, SB43 OFF; SB8 ON", True, "LPUART1_TX (PH3) on CN4 pin 35 (GPIO54) connected to T_VCP2_RX."), + ), + _bridge( + "SB16 SB38 SB25", + "VCP2", + "PB15", + ("SB16 OFF, SB38 OFF; SB25 ON", True, "LPUART1_CTS (PB15) on CN4 pin 26 (GPIO46) connected to T_VCP2_RTS."), + ), + _bridge( + "SB35 SB23", + "VCP2", + "PA10", + ("SB35 OFF; SB23 ON", True, "LPUART1_RTS (PA10) on CN3 pin 2 (GPIO2) connected to T_VCP2_CTS."), + ), + ], +} diff --git a/test/html2pinout_test.py b/test/html2pinout_test.py new file mode 100644 index 0000000..7bf95c2 --- /dev/null +++ b/test/html2pinout_test.py @@ -0,0 +1,75 @@ +# Copyright 2026, Niklas Hauser +# SPDX-License-Identifier: MPL-2.0 + +import re + +from modm_data.html2pinout import _groups, _signal, _field +from modm_data.html2pinout.signals import _USES + + +def test_groups(): + # repeated, mirrored, and mirrored with connector columns and a spacer + assert _groups(["Pin", "Pin name", "STM32 pin", "Pin", "Pin name", "STM32 pin"]) == {0: [1, 2], 3: [4, 5]} + assert _groups(["Pin", "Name", "Name", "Pin", "Pin", "Name", "Name", "Pin"]) == {0: [1], 3: [2], 4: [5], 7: [6]} + header = ["Connector", "Pin number", "Pin name", "MCU pin", "", "MCU pin", "Pin name", "Pin number", "Connector"] + assert _groups(header) == {1: [0, 2, 3], 7: [8, 5, 6]} + assert _groups(["Definition", "Bridge"]) == {} + + +def test_normalize(): + assert _signal("PC8/PA9") == ("PC8", "gpio") + assert _signal("PH3_BOOT0") == ("PH3", "gpio") + assert _signal("GPIO13") == ("GPIO13", "other") + assert _signal("+3.3 V") == _signal("3V3") == ("3V3", "power") + assert _signal("U5V") == _signal("VBUS_STL K") == _signal("VBUS_STLK 2)") == ("5V_STLK", "power") + assert _signal("RESET") == ("NRST", "system") + assert _signal("nc (reserved for tests)") == _signal("-") == ("NC", "nc") + assert _signal("AGND") == ("AGND", "ground") + assert _signal("VCP_TX") == ("VCP_TX", "other") + assert [_field(h) for h in ["STM32H7 pin", "MCU pin name", "Signal name", "MCU function", "Pin name"]] == [ + "pin", + "pin", + "net", + "function", + "label", + ] + + +def test_uses(): + def use(label): + return next((use for use, pattern in _USES.items() if re.search(pattern, label)), "other") + + labels = ["T_SWDIO", "STLK_RX", "USART_TX", "RMII_MDC", "USB_DM", "RCC_OSC_IN", "LD2", "USER_LED", "B1", "FE_CTRL3"] + assert [use(label) for label in labels] == [ + "debug", + "vcp", + "vcp", + "ethernet", + "usb", + "clock", + "led", + "led", + "button", + "other", + ] + + +def test_bridges(): + from modm_data.html2pinout import _BRIDGE, _GPIO, _STLINK + + assert _BRIDGE.findall("SB54, SB55 (X3 crystal)") == ["SB54", "SB55"] + # The pins of the ST-LINK microcontroller must not be linked to the board + text = "PA2 and PA3 on ST-LINK STM32F103CBT6 are connected to PD8 and PD9" + assert _GPIO.findall(_STLINK.sub("", text)) == ["PD8", "PD9"] + # Brackets list the GPIO of each bridge or name the whole group + cell = "SB58 (PA1), SB62 (PC1) (Ethernet)" + assert re.findall(r"\(([^()]*)\)", cell) == ["PA1", "PC1", "Ethernet"] + + +def test_transcribed(): + from modm_data.html2pinout.transcribed import TRANSCRIBED + + sizes = {doc: [len(names.split()) for names in connectors.values()] for doc, (_, connectors) in TRANSCRIBED.items()} + assert sizes == {"UM2324": [38, 38], "UM2206": [38, 38], "UM2953": [38, 38], "UM2592": [12, 12], "UM2581": [2, 2]} + assert _signal("5V-USB-CHG") == ("5V_USB_CHGR", "power") + assert _signal("5V-STLINK") == ("5V_STLK", "power") diff --git a/tools/make/common.mk b/tools/make/common.mk index c8502b2..2a2113d 100644 --- a/tools/make/common.mk +++ b/tools/make/common.mk @@ -68,6 +68,9 @@ build-homepage: --names ext/cache/svd2variants/feature-names-gemma.json --output docs/src/variants/ @curl -fsSL --create-dirs -o docs/src/variants/index.html \ https://gist.githubusercontent.com/salkinium/12a18032caa303697c6583937f6fcd16/raw/variants.html + @python3 -m modm_data.html2pinout --output docs/src/boards/nucleo.json + @curl -fsSL --create-dirs -o docs/src/boards/index.html \ + https://gist.githubusercontent.com/salkinium/12a18032caa303697c6583937f6fcd16/raw/boards.html @(cd docs && mkdocs build)