Skip to content

Commit c995128

Browse files
committed
doc: boards: extensions: add Sphinx directive for board supported hardware
Introduce a new directive for displaying hardware features supported by a board, using information available in the devicetree. Signed-off-by: Benjamin Cabé <[email protected]>
1 parent 9f18d3d commit c995128

File tree

6 files changed

+266
-12
lines changed

6 files changed

+266
-12
lines changed

doc/_extensions/zephyr/domain/__init__.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"""
2828

2929
import json
30+
import re
3031
import sys
3132
from collections.abc import Iterator
3233
from os import path
@@ -65,6 +66,43 @@
6566
TEMPLATES_DIR = Path(__file__).parent / "templates"
6667
RESOURCES_DIR = Path(__file__).parent / "static"
6768

69+
# Load and parse binding types from text file
70+
BINDINGS_TXT_PATH = ZEPHYR_BASE / "dts" / "bindings" / "binding-types.txt"
71+
ACRONYM_PATTERN = re.compile(r'([a-zA-Z0-9-]+)\s*\((.*?)\)')
72+
BINDING_TYPE_TO_DOCUTILS_NODE = {}
73+
74+
75+
def parse_text_with_acronyms(text):
76+
"""Parse text that may contain acronyms into a list of nodes."""
77+
result = nodes.inline()
78+
last_end = 0
79+
80+
for match in ACRONYM_PATTERN.finditer(text):
81+
# Add any text before the acronym
82+
if match.start() > last_end:
83+
result += nodes.Text(text[last_end : match.start()])
84+
85+
# Add the acronym
86+
abbr, explanation = match.groups()
87+
result += nodes.abbreviation(abbr, abbr, explanation=explanation)
88+
last_end = match.end()
89+
90+
# Add any remaining text
91+
if last_end < len(text):
92+
result += nodes.Text(text[last_end:])
93+
94+
return result
95+
96+
97+
with open(BINDINGS_TXT_PATH) as f:
98+
for line in f:
99+
line = line.strip()
100+
if not line or line.startswith('#'):
101+
continue
102+
103+
key, value = line.split('\t', 1)
104+
BINDING_TYPE_TO_DOCUTILS_NODE[key] = parse_text_with_acronyms(value)
105+
68106
logger = logging.getLogger(__name__)
69107

70108

@@ -685,6 +723,7 @@ def run(self):
685723
board_node = BoardNode(id=board_name)
686724
board_node["full_name"] = board["full_name"]
687725
board_node["vendor"] = vendors.get(board["vendor"], board["vendor"])
726+
board_node["supported_features"] = board["supported_features"]
688727
board_node["archs"] = board["archs"]
689728
board_node["socs"] = board["socs"]
690729
board_node["image"] = board["image"]
@@ -716,6 +755,137 @@ def run(self):
716755
return [nodes.paragraph(text="Board catalog is only available in HTML.")]
717756

718757

758+
class BoardSupportedHardwareDirective(SphinxDirective):
759+
"""A directive for showing the supported hardware features of a board."""
760+
761+
has_content = False
762+
required_arguments = 0
763+
optional_arguments = 0
764+
765+
def run(self):
766+
env = self.env
767+
docname = env.docname
768+
769+
matcher = NodeMatcher(BoardNode)
770+
board_nodes = list(self.state.document.traverse(matcher))
771+
if not board_nodes:
772+
logger.warning(
773+
"board-supported-hw directive must be used in a board documentation page.",
774+
location=(docname, self.lineno),
775+
)
776+
return []
777+
778+
board_node = board_nodes[0]
779+
supported_features = board_node["supported_features"]
780+
result_nodes = []
781+
782+
paragraph = nodes.paragraph()
783+
paragraph += nodes.Text("The ")
784+
paragraph += nodes.literal(text=board_node["id"])
785+
paragraph += nodes.Text(" board supports the hardware features listed below.")
786+
result_nodes.append(paragraph)
787+
788+
if not env.app.config.zephyr_generate_hw_features:
789+
note = nodes.admonition()
790+
note += nodes.title(text="Note")
791+
note["classes"].append("warning")
792+
note += nodes.paragraph(
793+
text="The list of supported hardware features was not generated. Run a full "
794+
"documentation build for the required metadata to be available."
795+
)
796+
result_nodes.append(note)
797+
return result_nodes
798+
799+
# Add the note before any tables
800+
note = nodes.admonition()
801+
note += nodes.title(text="Note")
802+
note["classes"].append("note")
803+
note += nodes.paragraph(
804+
text="The tables below were automatically generated using information from the "
805+
"Devicetree. They may not be fully representative of all the hardware features "
806+
"supported by the board."
807+
)
808+
result_nodes.append(note)
809+
810+
for target, features in sorted(supported_features.items()):
811+
if not features:
812+
continue
813+
814+
target_heading = nodes.section(ids=[f"{board_node['id']}-{target}-hw-features"])
815+
heading = nodes.title()
816+
heading += nodes.literal(text=target)
817+
heading += nodes.Text(" target")
818+
target_heading += heading
819+
result_nodes.append(target_heading)
820+
821+
table = nodes.table(classes=["colwidths-given"])
822+
tgroup = nodes.tgroup(cols=3)
823+
824+
tgroup += nodes.colspec(colwidth=20, classes=["col-1"])
825+
tgroup += nodes.colspec(colwidth=50)
826+
tgroup += nodes.colspec(colwidth=30)
827+
828+
thead = nodes.thead()
829+
row = nodes.row()
830+
headers = ["Type", "Description", "Compatible"]
831+
for header in headers:
832+
row += nodes.entry("", nodes.paragraph(text=header))
833+
thead += row
834+
tgroup += thead
835+
836+
tbody = nodes.tbody()
837+
838+
def feature_sort_key(feature):
839+
# Put "CPU" first. Later updates might also give priority to features
840+
# like "sensor"s, for example.
841+
if feature == "cpu":
842+
return (0, feature)
843+
return (1, feature)
844+
845+
sorted_features = sorted(features.keys(), key=feature_sort_key)
846+
847+
for feature in sorted_features:
848+
items = list(features[feature].items())
849+
num_items = len(items)
850+
851+
for i, (key, value) in enumerate(items):
852+
row = nodes.row()
853+
854+
# Add type column only for first row of a feature
855+
if i == 0:
856+
type_entry = nodes.entry(morerows=num_items - 1)
857+
type_entry += nodes.paragraph(
858+
"",
859+
"",
860+
BINDING_TYPE_TO_DOCUTILS_NODE.get(
861+
feature, nodes.Text(feature)
862+
).deepcopy(),
863+
)
864+
row += type_entry
865+
866+
row += nodes.entry("", nodes.paragraph(text=value))
867+
868+
# Create compatible xref
869+
xref = addnodes.pending_xref(
870+
"",
871+
refdomain="std",
872+
reftype="dtcompatible",
873+
reftarget=key,
874+
refexplicit=False,
875+
refwarn=True,
876+
)
877+
xref += nodes.literal(text=key)
878+
row += nodes.entry("", nodes.paragraph("", "", xref))
879+
880+
tbody += row
881+
882+
tgroup += tbody
883+
table += tgroup
884+
result_nodes.append(table)
885+
886+
return result_nodes
887+
888+
719889
class ZephyrDomain(Domain):
720890
"""Zephyr domain"""
721891

@@ -734,6 +904,7 @@ class ZephyrDomain(Domain):
734904
"code-sample-category": CodeSampleCategoryDirective,
735905
"board-catalog": BoardCatalogDirective,
736906
"board": BoardDirective,
907+
"board-supported-hw": BoardSupportedHardwareDirective,
737908
}
738909

739910
object_types: dict[str, ObjType] = {

doc/_extensions/zephyr/domain/templates/board-card.html

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,17 @@
1515
data-arch="{{ board.archs | join(" ") }}"
1616
data-vendor="{{ board.vendor }}"
1717
data-socs="{{ board.socs | join(" ") }}"
18-
data-supported-features="{{ board.supported_features | join(" ") }}" tabindex="0">
18+
data-supported-features="
19+
{%- set feature_types = [] -%}
20+
{%- for target_features in board.supported_features.values() -%}
21+
{%- for feature_type in target_features.keys() -%}
22+
{%- if feature_type not in feature_types -%}
23+
{%- set _ = feature_types.append(feature_type) -%}
24+
{%- endif -%}
25+
{%- endfor -%}
26+
{%- endfor -%}
27+
{{- feature_types|join(' ') -}}
28+
" tabindex="0">
1929
<div class="vendor">{{ vendors[board.vendor] }}</div>
2030
{% if board.image -%}
2131
<img alt="A picture of the {{ board.full_name }} board"

doc/_scripts/gen_boards_catalog.py

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,59 @@
2222
logger = logging.getLogger(__name__)
2323

2424

25+
class DeviceTreeUtils:
26+
_compat_description_cache = {}
27+
28+
@classmethod
29+
def get_first_sentence(cls, text):
30+
"""Extract the first sentence from a text block (typically a node description).
31+
32+
Args:
33+
text: The text to extract the first sentence from.
34+
35+
Returns:
36+
The first sentence found in the text, or the entire text if no sentence
37+
boundary is found.
38+
"""
39+
# Split the text into lines
40+
lines = text.splitlines()
41+
42+
# Trim leading and trailing whitespace from each line and ignore completely blank lines
43+
lines = [line.strip() for line in lines]
44+
45+
if not lines:
46+
return ""
47+
48+
# Case 1: Single line followed by blank line(s) or end of text
49+
if len(lines) == 1 or (len(lines) > 1 and lines[1] == ""):
50+
first_line = lines[0]
51+
# Check for the first period
52+
period_index = first_line.find(".")
53+
# If there's a period, return up to the period; otherwise, return the full line
54+
return first_line[: period_index + 1] if period_index != -1 else first_line
55+
56+
# Case 2: Multiple contiguous lines, treat as a block
57+
block = " ".join(lines)
58+
period_index = block.find(".")
59+
# If there's a period, return up to the period; otherwise, return the full block
60+
return block[: period_index + 1] if period_index != -1 else block
61+
62+
@classmethod
63+
def get_cached_description(cls, node):
64+
"""Get the cached description for a devicetree node.
65+
66+
Args:
67+
node: A devicetree node object with matching_compat and description attributes.
68+
69+
Returns:
70+
The cached description for the node's compatible, creating it if needed.
71+
"""
72+
return cls._compat_description_cache.setdefault(
73+
node.matching_compat,
74+
cls.get_first_sentence(node.description)
75+
)
76+
77+
2578
def guess_file_from_patterns(directory, patterns, name, extensions):
2679
for pattern in patterns:
2780
for ext in extensions:
@@ -197,13 +250,10 @@ def get_catalog(generate_hw_features=False):
197250
doc_page = guess_doc_page(board)
198251

199252
supported_features = {}
200-
targets = set()
201253

202254
# Use pre-gathered build info and DTS files
203255
if board.name in board_devicetrees:
204256
for board_target, edt in board_devicetrees[board.name].items():
205-
targets.add(board_target)
206-
207257
okay_nodes = [
208258
node
209259
for node in edt.nodes
@@ -218,13 +268,13 @@ def get_catalog(generate_hw_features=False):
218268
if binding_path.is_relative_to(ZEPHYR_BINDINGS)
219269
else "misc"
220270
)
221-
target_features.setdefault(binding_type, set()).add(node.matching_compat)
222-
271+
description = DeviceTreeUtils.get_cached_description(node)
272+
target_features.setdefault(binding_type, {}).setdefault(
273+
node.matching_compat, description
274+
)
223275

224-
# for now we do the union of all supported features for all of board's targets but
225-
# in the future it's likely the catalog will be organized so that the list of
226-
# supported features is also available per target.
227-
supported_features.update(target_features)
276+
# Store features for this specific target
277+
supported_features[board_target] = target_features
228278

229279
# Grab all the twister files for this board and use them to figure out all the archs it
230280
# supports.
@@ -246,7 +296,6 @@ def get_catalog(generate_hw_features=False):
246296
"archs": list(archs),
247297
"socs": list(socs),
248298
"supported_features": supported_features,
249-
"targets": list(targets),
250299
"image": guess_image(board),
251300
}
252301

doc/_static/css/custom.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,3 +1190,12 @@ li>a.code-sample-link.reference.internal.current {
11901190
text-overflow: ellipsis;
11911191
max-width:80%;
11921192
}
1193+
1194+
#supported-features>div>table>tbody>tr>td>p {
1195+
white-space: normal;
1196+
}
1197+
1198+
#supported-features td {
1199+
background-color: var(--table-row-odd-background-color);
1200+
border-left-width: 1px;
1201+
}

doc/contribute/documentation/guidelines.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,6 +1231,20 @@ Boards
12311231
This directive is used to generate a catalog of Zephyr-supported boards that can be used to
12321232
quickly browse the list of all supported boards and filter them according to various criteria.
12331233

1234+
.. rst:directive:: .. zephyr:board-supported-hw::
1235+
1236+
This directive is used to show supported hardware features for all the targets of the board
1237+
documented in the current page. The tables are automatically generated based on the board's
1238+
Devicetree.
1239+
1240+
The directive must be used in a document that also contains a :rst:dir:`zephyr:board` directive,
1241+
as it relies on the board information to generate the table.
1242+
1243+
.. note::
1244+
1245+
This directive requires that the documentation is built with hardware features generation enabled
1246+
(``zephyr_generate_hw_features`` config option set to ``True``). If disabled, a warning message
1247+
will be shown instead of the hardware features tables.
12341248

12351249
References
12361250
**********

doc/templates/board.tmpl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ Hardware
1717

1818
Supported Features
1919
==================
20-
[List of supported features and level of support in Zephyr]
20+
21+
.. zephyr:board-supported-hw::
2122

2223
Connections and IOs
2324
===================

0 commit comments

Comments
 (0)