Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand Down
1 change: 1 addition & 0 deletions docs/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,4 @@ nav:
- Pinouts: pinout/index.html
- SVD Explorer: svd/index.html
- Peripheral Variants: variants/index.html
- NUCLEO Pinouts: boards/index.html
334 changes: 334 additions & 0 deletions src/modm_data/html2pinout/__init__.py

Large diffs are not rendered by default.

92 changes: 92 additions & 0 deletions src/modm_data/html2pinout/__main__.py
Original file line number Diff line number Diff line change
@@ -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)
124 changes: 124 additions & 0 deletions src/modm_data/html2pinout/signals.py
Original file line number Diff line number Diff line change
@@ -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()))
Loading
Loading