modm_data.nrfx.pinout

Nordic Product Specification Pinouts

The package pinouts and special pin functions of the nRF devices are not part of the nrfx MDK, but only described in the pin assignment chapters of the Nordic product specifications. Since the Nordic documentation is protected by a bot challenge, it cannot be downloaded automatically, so the data is extracted manually and stored in this package as data/pinout.json. The pipeline only reads this JSON file and does not require any network access.

Updating the Pinout Data

The JSON file only needs to be updated for new products or data corrections:

  1. Open the pin assignment chapter of the product specification in a browser, for example, https://docs.nordicsemi.com/r/bundle/ps_nrf52840/page/pin.html. All products are listed in the product overview, for example, https://docs.nordicsemi.com/r/bundle/additionalresources/page/additionalresources/nrf53-series/nrf5340.
  2. The page content is rendered with JavaScript, so saving the page source only stores an empty shell. Instead, copy the rendered page: In Safari, open Develop → Show Web Inspector, right-click the <html> element in the Elements tab, and select Copy → HTML.
  3. Paste the HTML into ext/nordic/pinout/ps_{product}-pin.html, for example, ext/nordic/pinout/ps_nrf52840-pin.html.
  4. Convert the saved chapters and merge them into the JSON file:

    make convert-nordic-pinout
    
  5. Add the package codes of new products to package_code_map.

  6. Review the changes of the JSON file and commit only the JSON file, since the HTML files are subject to Nordic's copyright.

The parser in pinout_from_html() expects tables with a caption containing "Pin assignments" or "Ball assignments" and the columns "Pin" and "Name", and a table captioned "Special GPIO considerations". If the documentation layout changes, the parser needs to be adapted and the JSON output compared with the previous version.

  1# Copyright 2020, Hannes Ellinger
  2# Copyright 2026, Niklas Hauser
  3# SPDX-License-Identifier: MPL-2.0
  4
  5"""
  6# Nordic Product Specification Pinouts
  7
  8The package pinouts and special pin functions of the nRF devices are not part
  9of the nrfx MDK, but only described in the pin assignment chapters of the
 10Nordic product specifications. Since the Nordic documentation is protected by a
 11bot challenge, it cannot be downloaded automatically, so the data is extracted
 12manually and stored in this package as `data/pinout.json`. The pipeline only
 13reads this JSON file and does not require any network access.
 14
 15## Updating the Pinout Data
 16
 17The JSON file only needs to be updated for new products or data corrections:
 18
 191. Open the pin assignment chapter of the product specification in a browser,
 20   for example, <https://docs.nordicsemi.com/r/bundle/ps_nrf52840/page/pin.html>.
 21   All products are listed in the product overview, for example,
 22   <https://docs.nordicsemi.com/r/bundle/additionalresources/page/additionalresources/nrf53-series/nrf5340>.
 232. The page content is rendered with JavaScript, so saving the page source only
 24   stores an empty shell. Instead, copy the rendered page: In Safari, open
 25   *Develop → Show Web Inspector*, right-click the `<html>` element in the
 26   *Elements* tab, and select *Copy → HTML*.
 273. Paste the HTML into `ext/nordic/pinout/ps_{product}-pin.html`, for example,
 28   `ext/nordic/pinout/ps_nrf52840-pin.html`.
 294. Convert the saved chapters and merge them into the JSON file:
 30   ```sh
 31   make convert-nordic-pinout
 32   ```
 335. Add the package codes of new products to `package_code_map`.
 346. Review the changes of the JSON file and commit only the JSON file, since the
 35   HTML files are subject to Nordic's copyright.
 36
 37The parser in `pinout_from_html()` expects tables with a caption containing
 38"Pin assignments" or "Ball assignments" and the columns "Pin" and "Name", and
 39a table captioned "Special GPIO considerations". If the documentation layout
 40changes, the parser needs to be adapted and the JSON output compared with the
 41previous version.
 42"""
 43
 44import re
 45import json
 46import logging
 47from pathlib import Path
 48from functools import cache
 49from importlib.resources import files
 50
 51from lxml import etree
 52
 53LOGGER = logging.getLogger(__name__)
 54
 55PRODUCT_SPECIFICATION_URL = "https://docs.nordicsemi.com/r/bundle/ps_{product}/page/pin.html"
 56
 57# Maps the package names of the product specification to the package codes of the device names
 58package_code_map = {
 59    "nrf52805": {"WLCSP": ["ca"]},
 60    "nrf52810": {"QFN48": ["qf"], "QFN32": ["qc"], "WLCSP": ["ca"]},
 61    "nrf52811": {"QFN48": ["qf"], "QFN32": ["qc"], "WLCSP": ["ca"]},
 62    "nrf52820": {"QFN40": ["qd"], "WLCSP": ["cf"]},
 63    "nrf52832": {"QFN48": ["qf"], "WLCSP": ["ci"]},
 64    "nrf52833": {"aQFN73": ["qi"], "QFN40": ["qd"], "WLCSP": ["cj"]},
 65    "nrf52840": {"aQFN73": ["qi"], "QFN48": ["qf"], "WLCSP": ["ck"]},
 66    "nrf5340": {"aQFN94": ["qk"], "WLCSP": ["cl", "cm"]},
 67}
 68
 69
 70@cache
 71def _pinout_data() -> dict:
 72    return json.loads((files(__package__) / "data" / "pinout.json").read_text())
 73
 74
 75def pinout(product: str) -> dict:
 76    """
 77    Returns the package pinouts and the special pin functions of a product.
 78    The data is extracted from the pin assignment chapter of the product
 79    specification HTML and stored as JSON in this package.
 80
 81    :param product: The product name, for example, `nrf52840`.
 82    :return: Dictionary with `packages` list (each with `name`, `pins`, and `codes`)
 83             and `specials` mapping of `port.pin` to a sorted list of tags.
 84    """
 85    data = _pinout_data().get(product, {"packages": [], "specials": {}})
 86    codes = package_code_map.get(product, {})
 87    packages = [package | {"codes": codes.get(package["name"], [])} for package in data["packages"]]
 88    return {"packages": packages, "specials": data["specials"]}
 89
 90
 91def _text_lines(element):
 92    lines = [line.strip() for line in element.xpath('.//p[contains(@class, "lines")]/text()') if line.strip()]
 93    if lines:
 94        return lines
 95    return [line.strip() for line in element.xpath(".//text()") if line.strip()]
 96
 97
 98def _parse_gpio_ref(token):
 99    if token is None:
100        return None
101    cleaned = token.strip().upper().rstrip(".,;:")
102    if not cleaned.startswith("P") or "." not in cleaned:
103        return None
104    port_text, pin_text = cleaned[1:].split(".", 1)
105    if not port_text.isdigit():
106        return None
107    pin_digits = []
108    for character in pin_text:
109        if not character.isdigit():
110            break
111        pin_digits.append(character)
112    if not pin_digits:
113        return None
114    return str(int(port_text)), str(int("".join(pin_digits)))
115
116
117def _extract_package_name(caption):
118    compact = re.sub(r"\s+", " ", caption or "").strip()
119    if match := re.search(r"(aQFN\d+|QFN\d+|WLCSP\d+|WLCSP)", compact, re.IGNORECASE):
120        return match.group(1).replace("aqfn", "aQFN").replace("wlcsp", "WLCSP")
121    return None
122
123
124def _extract_pin_refs(pin_text):
125    tokenized = pin_text or ""
126    for separator in ("-", "/", ",", ";", ":", "(", ")", "[", "]"):
127        tokenized = tokenized.replace(separator, f" {separator} ")
128    refs = [ref for token in tokenized.split() if (ref := _parse_gpio_ref(token)) is not None]
129    if not refs:
130        return []
131    if len(refs) == 2 and "-" in (pin_text or ""):
132        first_port, first_pin = refs[0][0], int(refs[0][1])
133        second_port, second_pin = refs[1][0], int(refs[1][1])
134        if first_port == second_port and first_pin <= second_pin:
135            return [(first_port, str(pin)) for pin in range(first_pin, second_pin + 1)]
136    unique = []
137    for value in refs:
138        if value not in unique:
139            unique.append(value)
140    return unique
141
142
143def _extract_special_tags(text):
144    lower = text.lower()
145    tags = set()
146    for ain in re.findall(r"ain\s*(\d+)", lower):
147        tags.add(f"ain{ain}")
148    if "trace" in lower:
149        tags.add("trace")
150    if "traceclk" in lower:
151        tags.add("traceclk")
152    for tracedata in re.findall(r"tracedata\s*\[?(\d+)\]?", lower):
153        tags.add(f"tracedata{tracedata}")
154    if "serial wire output" in lower or re.search(r"\bswo\b", lower):
155        tags.add("swo")
156    if "qspi" in lower:
157        tags.add("qspi")
158        for signal in re.findall(r"\b(io[0-3]|sck|csn|dcx)\s+for\s+qspi\b", lower):
159            tags.add(f"qspi_{signal}")
160        for signal in re.findall(r"qspi\s*/\s*(csn|sck)\b", lower):
161            tags.add(f"qspi_{signal}")
162    if "spim4" in lower:
163        tags.add("spim4")
164        for signal in re.findall(r"\b(sck|mosi|miso|csn|dcx)\s+for\s+spim4\b", lower):
165            tags.add(f"spim4_{signal}")
166    if "twim" in lower:
167        tags.add("twim")
168    if "twis" in lower:
169        tags.add("twis")
170    if re.search(r"\btwi\b", lower):
171        tags.add("twi")
172    return tags
173
174
175def pinout_from_html(path: Path) -> dict:
176    """
177    Parses the pin assignment chapter of a Nordic product specification.
178
179    :param path: Path to the HTML file of the pin assignment chapter.
180    :return: Dictionary with `packages` and `specials` as described in `pinout()`.
181    """
182    tree = etree.parse(str(path), parser=etree.HTMLParser(recover=True))
183    package_pinouts = []
184    special_tags = {}
185
186    for table in tree.xpath("//table[caption]"):
187        caption_text = " ".join(text.strip() for text in table.xpath("./caption//text()") if text.strip())
188        caption_lower = caption_text.lower()
189
190        if "special gpio considerations" in caption_lower:
191            for row in table.xpath("./tbody/tr"):
192                cells = row.xpath("./td")
193                if len(cells) < 2:
194                    continue
195                refs = _extract_pin_refs(" ".join(_text_lines(cells[0])))
196                tags = _extract_special_tags(" ".join(_text_lines(cells[1])))
197                for ref in refs:
198                    special_tags.setdefault(ref, set()).update(tags)
199            continue
200
201        if "pin assignment" not in caption_lower and "ball assignment" not in caption_lower:
202            continue
203
204        package_name = _extract_package_name(caption_text)
205        if package_name is None:
206            context = table.xpath("ancestor::article[1]/@id | ancestor::article[1]/h2[1]//text()")
207            package_name = _extract_package_name(" ".join(text.strip() for text in context if text.strip()))
208        if package_name is None:
209            continue
210
211        headers = []
212        for header_cell in table.xpath("./thead//th"):
213            if header_text := " ".join(text.strip() for text in header_cell.xpath(".//text()") if text.strip()).lower():
214                headers.append(header_text)
215        if not headers:
216            continue
217
218        pin_idx = name_idx = function_idx = description_idx = recommended_idx = None
219        for index, header in enumerate(headers):
220            if pin_idx is None and header == "pin":
221                pin_idx = index
222            elif name_idx is None and header == "name":
223                name_idx = index
224            elif function_idx is None and "function" in header:
225                function_idx = index
226            elif description_idx is None and "description" in header:
227                description_idx = index
228            elif recommended_idx is None and "recommended" in header:
229                recommended_idx = index
230        if pin_idx is None or name_idx is None:
231            continue
232
233        def cell_text(cells, index):
234            return " ".join(_text_lines(cells[index])) if index is not None and index < len(cells) else ""
235
236        package_pins = []
237        for row in table.xpath("./tbody/tr"):
238            cells = row.xpath("./td")
239            if len(cells) <= max(pin_idx, name_idx):
240                continue
241            pin_position = " ".join(_text_lines(cells[pin_idx]))
242            name_lines = _text_lines(cells[name_idx])
243            if not pin_position or not name_lines:
244                continue
245
246            gpio_match = None
247            for line in name_lines:
248                if (ref := _parse_gpio_ref(line)) is not None:
249                    gpio_match = (ref[0], ref[1], f"P{int(ref[0])}.{int(ref[1]):02d}")
250                    break
251
252            function_text = cell_text(cells, function_idx)
253            pin_entry = {"position": pin_position, "name": gpio_match[2] if gpio_match else name_lines[0]}
254            if gpio_match is None and "power" in function_text.lower():
255                pin_entry["type"] = "power"
256            package_pins.append(pin_entry)
257
258            if gpio_match is not None:
259                row_text = " ".join(name_lines + [function_text, cell_text(cells, description_idx)])
260                row_text += " " + cell_text(cells, recommended_idx)
261                if tags := _extract_special_tags(row_text):
262                    special_tags.setdefault((gpio_match[0], gpio_match[1]), set()).update(tags)
263
264        if package_pins:
265            package_pinouts.append({"name": package_name, "pins": package_pins})
266
267    return {
268        "packages": package_pinouts,
269        "specials": {f"{port}.{pin}": sorted(tags) for (port, pin), tags in special_tags.items() if tags},
270    }
271
272
273def write_pinout_json(html_folder: Path, json_path: Path = None) -> Path:
274    """
275    Converts all `ps_{product}-pin.html` files in a folder and merges them into
276    the pinout JSON file of this package, so that only the chapters of new or
277    changed products need to be saved. The files are the pin assignment chapters
278    saved from `PRODUCT_SPECIFICATION_URL`, see the module documentation.
279
280    :param html_folder: Folder containing the product specification pin chapters.
281    :param json_path: Optional output path, defaults to the package data file.
282    :return: Path to the written JSON file.
283    """
284    json_path = Path(json_path or (files(__package__) / "data" / "pinout.json"))
285    data = json.loads(json_path.read_text()) if json_path.exists() else {}
286    for path in sorted(Path(html_folder).glob("ps_*-pin.html")):
287        product = path.name[3:].split("-")[0]
288        data[product] = pinout_from_html(path)
289        LOGGER.info("%s: %d packages", product, len(data[product]["packages"]))
290    json_path.write_text(json.dumps(dict(sorted(data.items())), indent=2) + "\n")
291    return json_path
LOGGER = <Logger modm_data.nrfx.pinout (WARNING)>
PRODUCT_SPECIFICATION_URL = 'https://docs.nordicsemi.com/r/bundle/ps_{product}/page/pin.html'
package_code_map = {'nrf52805': {'WLCSP': ['ca']}, 'nrf52810': {'QFN48': ['qf'], 'QFN32': ['qc'], 'WLCSP': ['ca']}, 'nrf52811': {'QFN48': ['qf'], 'QFN32': ['qc'], 'WLCSP': ['ca']}, 'nrf52820': {'QFN40': ['qd'], 'WLCSP': ['cf']}, 'nrf52832': {'QFN48': ['qf'], 'WLCSP': ['ci']}, 'nrf52833': {'aQFN73': ['qi'], 'QFN40': ['qd'], 'WLCSP': ['cj']}, 'nrf52840': {'aQFN73': ['qi'], 'QFN48': ['qf'], 'WLCSP': ['ck']}, 'nrf5340': {'aQFN94': ['qk'], 'WLCSP': ['cl', 'cm']}}
def pinout(product: str) -> dict:
76def pinout(product: str) -> dict:
77    """
78    Returns the package pinouts and the special pin functions of a product.
79    The data is extracted from the pin assignment chapter of the product
80    specification HTML and stored as JSON in this package.
81
82    :param product: The product name, for example, `nrf52840`.
83    :return: Dictionary with `packages` list (each with `name`, `pins`, and `codes`)
84             and `specials` mapping of `port.pin` to a sorted list of tags.
85    """
86    data = _pinout_data().get(product, {"packages": [], "specials": {}})
87    codes = package_code_map.get(product, {})
88    packages = [package | {"codes": codes.get(package["name"], [])} for package in data["packages"]]
89    return {"packages": packages, "specials": data["specials"]}

Returns the package pinouts and the special pin functions of a product. The data is extracted from the pin assignment chapter of the product specification HTML and stored as JSON in this package.

Parameters
  • product: The product name, for example, nrf52840.
Returns

Dictionary with packages list (each with name, pins, and codes) and specials mapping of port.pin to a sorted list of tags.

def pinout_from_html(path: pathlib.Path) -> dict:
176def pinout_from_html(path: Path) -> dict:
177    """
178    Parses the pin assignment chapter of a Nordic product specification.
179
180    :param path: Path to the HTML file of the pin assignment chapter.
181    :return: Dictionary with `packages` and `specials` as described in `pinout()`.
182    """
183    tree = etree.parse(str(path), parser=etree.HTMLParser(recover=True))
184    package_pinouts = []
185    special_tags = {}
186
187    for table in tree.xpath("//table[caption]"):
188        caption_text = " ".join(text.strip() for text in table.xpath("./caption//text()") if text.strip())
189        caption_lower = caption_text.lower()
190
191        if "special gpio considerations" in caption_lower:
192            for row in table.xpath("./tbody/tr"):
193                cells = row.xpath("./td")
194                if len(cells) < 2:
195                    continue
196                refs = _extract_pin_refs(" ".join(_text_lines(cells[0])))
197                tags = _extract_special_tags(" ".join(_text_lines(cells[1])))
198                for ref in refs:
199                    special_tags.setdefault(ref, set()).update(tags)
200            continue
201
202        if "pin assignment" not in caption_lower and "ball assignment" not in caption_lower:
203            continue
204
205        package_name = _extract_package_name(caption_text)
206        if package_name is None:
207            context = table.xpath("ancestor::article[1]/@id | ancestor::article[1]/h2[1]//text()")
208            package_name = _extract_package_name(" ".join(text.strip() for text in context if text.strip()))
209        if package_name is None:
210            continue
211
212        headers = []
213        for header_cell in table.xpath("./thead//th"):
214            if header_text := " ".join(text.strip() for text in header_cell.xpath(".//text()") if text.strip()).lower():
215                headers.append(header_text)
216        if not headers:
217            continue
218
219        pin_idx = name_idx = function_idx = description_idx = recommended_idx = None
220        for index, header in enumerate(headers):
221            if pin_idx is None and header == "pin":
222                pin_idx = index
223            elif name_idx is None and header == "name":
224                name_idx = index
225            elif function_idx is None and "function" in header:
226                function_idx = index
227            elif description_idx is None and "description" in header:
228                description_idx = index
229            elif recommended_idx is None and "recommended" in header:
230                recommended_idx = index
231        if pin_idx is None or name_idx is None:
232            continue
233
234        def cell_text(cells, index):
235            return " ".join(_text_lines(cells[index])) if index is not None and index < len(cells) else ""
236
237        package_pins = []
238        for row in table.xpath("./tbody/tr"):
239            cells = row.xpath("./td")
240            if len(cells) <= max(pin_idx, name_idx):
241                continue
242            pin_position = " ".join(_text_lines(cells[pin_idx]))
243            name_lines = _text_lines(cells[name_idx])
244            if not pin_position or not name_lines:
245                continue
246
247            gpio_match = None
248            for line in name_lines:
249                if (ref := _parse_gpio_ref(line)) is not None:
250                    gpio_match = (ref[0], ref[1], f"P{int(ref[0])}.{int(ref[1]):02d}")
251                    break
252
253            function_text = cell_text(cells, function_idx)
254            pin_entry = {"position": pin_position, "name": gpio_match[2] if gpio_match else name_lines[0]}
255            if gpio_match is None and "power" in function_text.lower():
256                pin_entry["type"] = "power"
257            package_pins.append(pin_entry)
258
259            if gpio_match is not None:
260                row_text = " ".join(name_lines + [function_text, cell_text(cells, description_idx)])
261                row_text += " " + cell_text(cells, recommended_idx)
262                if tags := _extract_special_tags(row_text):
263                    special_tags.setdefault((gpio_match[0], gpio_match[1]), set()).update(tags)
264
265        if package_pins:
266            package_pinouts.append({"name": package_name, "pins": package_pins})
267
268    return {
269        "packages": package_pinouts,
270        "specials": {f"{port}.{pin}": sorted(tags) for (port, pin), tags in special_tags.items() if tags},
271    }

Parses the pin assignment chapter of a Nordic product specification.

Parameters
  • path: Path to the HTML file of the pin assignment chapter.
Returns

Dictionary with packages and specials as described in pinout().

def write_pinout_json( html_folder: pathlib.Path, json_path: pathlib.Path = None) -> pathlib.Path:
274def write_pinout_json(html_folder: Path, json_path: Path = None) -> Path:
275    """
276    Converts all `ps_{product}-pin.html` files in a folder and merges them into
277    the pinout JSON file of this package, so that only the chapters of new or
278    changed products need to be saved. The files are the pin assignment chapters
279    saved from `PRODUCT_SPECIFICATION_URL`, see the module documentation.
280
281    :param html_folder: Folder containing the product specification pin chapters.
282    :param json_path: Optional output path, defaults to the package data file.
283    :return: Path to the written JSON file.
284    """
285    json_path = Path(json_path or (files(__package__) / "data" / "pinout.json"))
286    data = json.loads(json_path.read_text()) if json_path.exists() else {}
287    for path in sorted(Path(html_folder).glob("ps_*-pin.html")):
288        product = path.name[3:].split("-")[0]
289        data[product] = pinout_from_html(path)
290        LOGGER.info("%s: %d packages", product, len(data[product]["packages"]))
291    json_path.write_text(json.dumps(dict(sorted(data.items())), indent=2) + "\n")
292    return json_path

Converts all ps_{product}-pin.html files in a folder and merges them into the pinout JSON file of this package, so that only the chapters of new or changed products need to be saved. The files are the pin assignment chapters saved from PRODUCT_SPECIFICATION_URL, see the module documentation.

Parameters
  • html_folder: Folder containing the product specification pin chapters.
  • json_path: Optional output path, defaults to the package data file.
Returns

Path to the written JSON file.