From 949061c7bff8e18d167ead7a9d94325a885904e6 Mon Sep 17 00:00:00 2001 From: Niklas Hauser Date: Sat, 3 Oct 2026 16:09:30 +0200 Subject: [PATCH 1/4] [html2pinout] Extract the NUCLEO board pinouts from the user manuals The connector tables of the user manuals yield the ST morpho, ARDUINO and Zio pinouts with normalized signal names. The CubeMX database adds the alternate functions of the mounted device and what the board itself uses each pin for. The solder bridge tables are linked to the pins their descriptions mention. --- src/modm_data/html2pinout/__init__.py | 283 ++++++++++++++++++++++++++ src/modm_data/html2pinout/__main__.py | 72 +++++++ src/modm_data/html2pinout/signals.py | 116 +++++++++++ test/html2pinout_test.py | 66 ++++++ 4 files changed, 537 insertions(+) create mode 100644 src/modm_data/html2pinout/__init__.py create mode 100644 src/modm_data/html2pinout/__main__.py create mode 100644 src/modm_data/html2pinout/signals.py create mode 100644 test/html2pinout_test.py diff --git a/src/modm_data/html2pinout/__init__.py b/src/modm_data/html2pinout/__init__.py new file mode 100644 index 0000000..3af5d0d --- /dev/null +++ b/src/modm_data/html2pinout/__init__.py @@ -0,0 +1,283 @@ +# 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 + +__all__ = ["board_pinouts_from_user_manual", "board_bridges_from_user_manual"] + +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]: + boards = set(_BOARD.findall(html)) + # Family names like NUCLEO-WB55 are only prefixes of the real board names + return {b for b in boards if not any(o != b and o.startswith(b) for o in boards)} + + +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 = next((b for c in chapters[:1] if (b := _boards(c._path.read_text()))), None) + boards = boards or set().union(*(_boards(c._path.read_text()) for c in 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 + + 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, 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 = next((b for c in chapters[:1] if (b := _boards(c._path.read_text()))), None) + boards = boards or set().union(*(_boards(c._path.read_text()) for c in 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 bridge column is the one naming the most bridges, since the headers are ambiguous + 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 = named.index(max(named)) + 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: + entry = bridges[board].setdefault(ids, {"ids": list(ids), "name": name, "gpios": [], "states": []}) + entry.setdefault("pins", {}).update({i: g for i, g in pins.items() if g}) + entry["gpios"] = sorted(set(entry["gpios"]) | set(gpios)) + entry["states"].append( + { + "state": _text(table.cell(state, y)), + "default": "" in table.cell(state, y).html, + "text": text, + } + ) + + return {board: list(entries.values()) for board, entries in sorted(bridges.items())} diff --git a/src/modm_data/html2pinout/__main__.py b/src/modm_data/html2pinout/__main__.py new file mode 100644 index 0000000..1a1ca2f --- /dev/null +++ b/src/modm_data/html2pinout/__main__.py @@ -0,0 +1,72 @@ +# 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 +from modm_data.html2pinout.signals import board_uses, device_for_board, device_signals +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(): + pinouts[board] = {"board": board, "document": document.fullname, "connectors": connectors} + for board, bridges in board_bridges_from_user_manual(document).items(): + if board in pinouts: + pinouts[board]["bridges"] = bridges + + boards = [] + for board, data in sorted(pinouts.items()): + # Family names like NUCLEO-WB55 describe the same board as NUCLEO-WB55RG + if any(other != board and other.startswith(board) for other in pinouts): + 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}") + + 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..0a81988 --- /dev/null +++ b/src/modm_data/html2pinout/signals.py @@ -0,0 +1,116 @@ +# 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_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. + """ + paths = sorted((_cubemx_path() / "plugins/boardmanager/boards").glob(f"*_Nucleo_{board}_*_Board_AllConfig.ioc")) + if not paths: + 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/test/html2pinout_test.py b/test/html2pinout_test.py new file mode 100644 index 0000000..49ac62f --- /dev/null +++ b/test/html2pinout_test.py @@ -0,0 +1,66 @@ +# 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"] From 75524cab6e9412ab88dd71db616275baedebd792 Mon Sep 17 00:00:00 2001 From: Niklas Hauser Date: Sat, 3 Oct 2026 16:09:30 +0200 Subject: [PATCH 2/4] [docs] Publish the NUCLEO pinouts with the Pinout Explorer --- docs/.gitignore | 1 + docs/mkdocs.yml | 1 + tools/make/common.mk | 3 +++ 3 files changed, 5 insertions(+) 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/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) From c21c606fc2578da02656c963bcb19e8a6a8471b5 Mon Sep 17 00:00:00 2001 From: Niklas Hauser Date: Sat, 3 Oct 2026 19:18:25 +0200 Subject: [PATCH 3/4] [html2pinout] Transcribe missing pinouts and recover the bridge defaults The morpho pinouts that the manuals only show as a figure and the table rows lost at page breaks are transcribed by hand, as are the solder bridges that are only described in feature tables. Boards named only in the ordering information or whose name is a prefix of another board are no longer dropped. Bridges are kept per table, since boards made of two PCBs number them twice. Their default state is also taken from an older revision of the manual or from being the only described state. --- src/modm_data/html2pinout/__init__.py | 97 +++++++--- src/modm_data/html2pinout/__main__.py | 28 ++- src/modm_data/html2pinout/signals.py | 12 +- src/modm_data/html2pinout/transcribed.py | 224 +++++++++++++++++++++++ test/html2pinout_test.py | 9 + 5 files changed, 339 insertions(+), 31 deletions(-) create mode 100644 src/modm_data/html2pinout/transcribed.py diff --git a/src/modm_data/html2pinout/__init__.py b/src/modm_data/html2pinout/__init__.py index 3af5d0d..258b70e 100644 --- a/src/modm_data/html2pinout/__init__.py +++ b/src/modm_data/html2pinout/__init__.py @@ -12,8 +12,9 @@ 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"] +__all__ = ["board_pinouts_from_user_manual", "board_bridges_from_user_manual", "inherit_bridge_defaults"] LOGGER = logging.getLogger(__name__) @@ -25,7 +26,7 @@ _BRIDGE = re.compile(r"\b(?:SB|JP)\d+\b") _STLINK = re.compile(r"(?:P[A-K]\d+\W+(?:and\W+)?)+(?:on|of)\W+(?:the\W+)?ST-?LINK") _CN = re.compile(r"\bCN\d+\b") -_BOARD = re.compile(r"NUCLEO-(?:[FGLHUCN]|WBA?|WL)\d[0-9A-Z]*(?:-[PQ])?\b") +_BOARD = re.compile(r"NUCLEO-(?:[FGLHUCN]|WBA?|WL)\d(?![0-9A-Z]*XX)[0-9A-Z]*(?:-[PQ])?\b") # The manuals spell the same signal in many ways _SIGNALS = [ @@ -35,7 +36,8 @@ (r"\+?3(\.3 ?V|V3)", "3V3"), (r"\+?5 ?V( OUTPUT)?", "5V"), (r"5V_EXT", "E5V"), - (r"U5V|5V_(USB_)?STL(IN)?K|VBUS_STL ?K", "5V_STLK"), + (r"U5V|5V[-_](USB_)?STL(IN)?K|VBUS_STL ?K", "5V_STLK"), + (r"5V[-_]USB[-_]CHGR?", "5V_USB_CHGR"), (r"5V[_ ]INT.*", "5V_INT"), (r"IO ?REF|3V3 \(IOREF\)|3V3 I/O", "IOREF"), (r"VDDA|AVVD/VREF\+", "AVDD"), @@ -89,9 +91,13 @@ def _field(header: str) -> str: def _boards(html: str) -> set[str]: - boards = set(_BOARD.findall(html)) - # Family names like NUCLEO-WB55 are only prefixes of the real board names - return {b for b in boards if not any(o != b and o.startswith(b) for o in boards)} + 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]]: @@ -186,8 +192,7 @@ def board_pinouts_from_user_manual(document: Document) -> dict[str, dict[str, di pin), `label` (connector pin name), `net`, `function`, `remark` and `bridge`. """ chapters = sorted(document.chapters(), key=lambda c: c.number) - boards = next((b for c in chapters[:1] if (b := _boards(c._path.read_text()))), None) - boards = boards or set().union(*(_boards(c._path.read_text()) for c in chapters)) + boards = _document_boards(chapters) pinouts = defaultdict(dict) for chapter in chapters: @@ -216,6 +221,15 @@ def board_pinouts_from_user_manual(document: Document) -> dict[str, dict[str, di 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())) @@ -231,13 +245,12 @@ 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, gpios, pins, states: [{state, default, text}]}]` for all NUCLEO boards in + :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 = next((b for c in chapters[:1] if (b := _boards(c._path.read_text()))), None) - boards = boards or set().union(*(_boards(c._path.read_text()) for c in chapters)) + boards = _document_boards(chapters) bridges = defaultdict(dict) for chapter in chapters: @@ -246,12 +259,13 @@ def board_bridges_from_user_manual(document: Document) -> dict[str, list[dict]]: 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 bridge column is the one naming the most bridges, since the headers are ambiguous + # 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 = named.index(max(named)) + 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: @@ -269,15 +283,52 @@ def board_bridges_from_user_manual(document: Document) -> dict[str, list[dict]]: # 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: - entry = bridges[board].setdefault(ids, {"ids": list(ids), "name": name, "gpios": [], "states": []}) + # 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)) - entry["states"].append( - { - "state": _text(table.cell(state, y)), - "default": "" in table.cell(state, y).html, - "text": text, - } - ) - - return {board: list(entries.values()) for board, entries in sorted(bridges.items())} + 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 index 1a1ca2f..9093424 100644 --- a/src/modm_data/html2pinout/__main__.py +++ b/src/modm_data/html2pinout/__main__.py @@ -7,8 +7,12 @@ 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 -from modm_data.html2pinout.signals import board_uses, device_for_board, device_signals +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 @@ -39,15 +43,27 @@ def main(): 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} - for board, bridges in board_bridges_from_user_manual(document).items(): - if board in pinouts: + 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 describe the same board as NUCLEO-WB55RG - if any(other != board and other.startswith(board) for other in pinouts): + # 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"} diff --git a/src/modm_data/html2pinout/signals.py b/src/modm_data/html2pinout/signals.py index 0a81988..65e5a39 100644 --- a/src/modm_data/html2pinout/signals.py +++ b/src/modm_data/html2pinout/signals.py @@ -88,14 +88,22 @@ def device_signals(name: str) -> dict[str, dict[str, int | None]]: 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. """ - paths = sorted((_cubemx_path() / "plugins/boardmanager/boards").glob(f"*_Nucleo_{board}_*_Board_AllConfig.ioc")) - if not paths: + if not (paths := _board_files(board)): return {} pins = {} # The shortest name is the default configuration without TrustZone or multi-core variants 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 index 49ac62f..7bf95c2 100644 --- a/test/html2pinout_test.py +++ b/test/html2pinout_test.py @@ -64,3 +64,12 @@ def test_bridges(): # 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") From 9f89c6424be11efb804ea3b56a90277b67a9c852 Mon Sep 17 00:00:00 2001 From: Niklas Hauser Date: Sat, 3 Oct 2026 19:42:20 +0200 Subject: [PATCH 4/4] [ci] Build the board pinouts from the private archives The docs build did not clone the HTML and CubeMX archives, so the pinout pipeline silently wrote an empty file. It now fails without any manuals and pull requests upload the homepage as an artifact for review. --- .github/workflows/deploy-docs.yml | 25 +++++++++++++++++++++++++ src/modm_data/html2pinout/__main__.py | 4 ++++ 2 files changed, 29 insertions(+) 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/src/modm_data/html2pinout/__main__.py b/src/modm_data/html2pinout/__main__.py index 9093424..5abf93e 100644 --- a/src/modm_data/html2pinout/__main__.py +++ b/src/modm_data/html2pinout/__main__.py @@ -79,6 +79,10 @@ def main(): 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