Merge branch 'feat/error-code-registration' into 'master'

feat(esp_common): implement composable error code registration via link-time arrays

Closes IDF-15105 and IDF-15486

See merge request espressif/esp-idf!46125
This commit is contained in:
Guillaume Souchere
2026-06-01 09:40:37 +02:00
49 changed files with 1486 additions and 1367 deletions
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
"""
Pre-commit check: ensure every header defining ESP_ERR_* codes is registered
via idf_define_esp_err_codes() in its component's CMakeLists.txt.
Compares the set of headers found by scanning all components against those
explicitly listed in idf_define_esp_err_codes(HEADERS ...) calls.
"""
import os
import re
import sys
# Add tools/ to path so we can import err_codes_extract
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from err_codes_extract import EXCLUDE_SEARCH_DIRS
from err_codes_extract import extract_from_file
# Additional directories to exclude beyond those in err_codes_extract
EXTRA_EXCLUDE_DIRS = {'remote'}
# Pattern matching idf_define_esp_err_codes(HEADERS ...) in CMakeLists.txt
ERR_CODES_CALL = re.compile(r'idf_define_esp_err_codes\s*\(')
def find_all_err_headers(components_dir: str) -> dict[str, set[str]]:
"""
Scan all component headers for ESP_ERR_* definitions using
err_codes_extract.extract_from_file().
Returns:
dict mapping component name -> set of header paths (relative to component dir)
"""
exclude = EXCLUDE_SEARCH_DIRS | EXTRA_EXCLUDE_DIRS
result: dict[str, set[str]] = {}
for component_name in sorted(os.listdir(components_dir)):
component_dir = os.path.join(components_dir, component_name)
if not os.path.isdir(component_dir):
continue
for root, dirnames, filenames in os.walk(component_dir, topdown=True):
dirnames[:] = [d for d in dirnames if d not in exclude]
for filename in filenames:
if not filename.endswith('.h'):
continue
filepath = os.path.join(root, filename)
rel_path = os.path.relpath(filepath, component_dir)
if extract_from_file(filepath, rel_path):
result.setdefault(component_name, set()).add(rel_path)
return result
def parse_registered_headers(components_dir: str) -> dict[str, set[str]]:
"""
Parse all CMakeLists.txt files for idf_define_esp_err_codes(HEADERS ...) calls.
Returns:
dict mapping component name -> set of header paths (relative to component dir)
"""
result: dict[str, set[str]] = {}
for component_name in sorted(os.listdir(components_dir)):
component_dir = os.path.join(components_dir, component_name)
cmake_file = os.path.join(component_dir, 'CMakeLists.txt')
if not os.path.isfile(cmake_file):
continue
try:
with open(cmake_file, encoding='utf-8') as f:
content = f.read()
except (UnicodeDecodeError, OSError):
continue
# Find all idf_define_esp_err_codes() calls and extract HEADERS args
for match in ERR_CODES_CALL.finditer(content):
start = match.end()
# Find the matching closing paren, handling nesting
depth = 1
pos = start
while pos < len(content) and depth > 0:
if content[pos] == '(':
depth += 1
elif content[pos] == ')':
depth -= 1
pos += 1
args_str = content[start : pos - 1]
# Remove comments
args_str = re.sub(r'#.*$', '', args_str, flags=re.MULTILINE)
# Extract tokens after HEADERS keyword
headers_match = re.search(r'\bHEADERS\b\s+(.*)', args_str, re.DOTALL)
if headers_match:
tokens = headers_match.group(1).split()
# Stop at next keyword (uppercase word) or end
for token in tokens:
if re.match(r'^[A-Z_]+$', token):
break
# Normalize path separators
header_path = token.replace('\\', '/')
result.setdefault(component_name, set()).add(header_path)
return result
def main() -> int:
idf_path = os.environ.get('IDF_PATH', os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
components_dir = os.path.join(idf_path, 'components')
if not os.path.isdir(components_dir):
print(f'Error: components directory not found: {components_dir}', file=sys.stderr)
return 1
found_headers = find_all_err_headers(components_dir)
registered_headers = parse_registered_headers(components_dir)
errors: list[str] = []
for component, headers in sorted(found_headers.items()):
registered = registered_headers.get(component, set())
missing = headers - registered
if missing:
for header in sorted(missing):
errors.append(
f'{component}: {header} defines ESP_ERR_* codes but is not registered in idf_define_esp_err_codes()'
)
if errors:
print('Error code registration check failed:', file=sys.stderr)
for error in errors:
print(f' {error}', file=sys.stderr)
print(
f'\n{len(errors)} header(s) define ESP_ERR_* codes but are not registered.\n'
f"Add idf_define_esp_err_codes(HEADERS ...) to the component's CMakeLists.txt.",
file=sys.stderr,
)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())
+1
View File
@@ -57,6 +57,7 @@ tools/ci/check_api_violation.sh
tools/ci/check_build_test_rules.py
tools/ci/check_callgraph.py
tools/ci/check_codeowners.py
tools/ci/check_err_codes_registration.py
tools/ci/check_esp_memory_utils_headers.sh
tools/ci/check_examples_extra_component_dirs.sh
tools/ci/check_executables.py
-1
View File
@@ -41,7 +41,6 @@ tools/esp_app_trace/espytrace/apptrace.py
tools/esp_app_trace/espytrace/sysview.py
tools/esp_app_trace/logtrace_proc.py
tools/esp_app_trace/sysviewtrace_proc.py
tools/gen_esp_err_to_name.py
tools/gen_soc_caps_kconfig/test/test_gen_soc_caps_kconfig.py
tools/ldgen/ldgen.py
tools/ldgen/ldgen/entity.py
+153
View File
@@ -0,0 +1,153 @@
# SPDX-FileCopyrightText: 2025-2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
#
# CMake module for composable error code registration.
#
# Provides the idf_define_esp_err_codes() function that components call
# to register their error codes into a link-time array.
#
#
# idf_define_esp_err_codes
#
# Register error codes defined in the specified header files.
# This function:
# 1. Runs err_codes_extract.py to extract error codes from headers into a CSV
# 2. Runs err_codes_to_c.py to generate a C source file placing entries into
# the .esp_err_msg_tbl linker section
# 3. Adds the generated C source file to the current component
# 4. Adds -Wl,--undefined to force-link the error code symbols
#
# Usage (must be called AFTER idf_component_register):
# idf_define_esp_err_codes(HEADERS include/my_errors.h include/other.h)
#
# Note: This function relies on COMPONENT_LIB and COMPONENT_NAME which are
# only available after idf_component_register() has been called.
#
function(idf_define_esp_err_codes)
# If error-to-name lookup is disabled, do nothing
if(NOT CONFIG_ESP_ERR_TO_NAME_LOOKUP)
return()
endif()
set(options "")
set(single_value "")
set(multi_value HEADERS)
cmake_parse_arguments(ARG "${options}" "${single_value}" "${multi_value}" ${ARGN})
idf_build_get_property(idf_path IDF_PATH)
idf_build_get_property(build_dir BUILD_DIR)
idf_build_get_property(python PYTHON)
# Determine component name
if(NOT COMPONENT_NAME)
get_filename_component(COMPONENT_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
endif()
# Collect header files
set(header_files "")
# Process explicit headers (relative to component dir)
foreach(header ${ARG_HEADERS})
if(IS_ABSOLUTE ${header})
list(APPEND header_files "${header}")
else()
list(APPEND header_files "${CMAKE_CURRENT_LIST_DIR}/${header}")
endif()
endforeach()
if(NOT header_files)
message(FATAL_ERROR "idf_define_esp_err_codes: No header files found for component ${COMPONENT_NAME}")
endif()
# Output paths
set(csv_output "${build_dir}/esp_err_codes/${COMPONENT_NAME}_err_codes.csv")
set(c_output "${build_dir}/esp_err_codes/${COMPONENT_NAME}_esp_err_codes.c")
# Ensure output directory exists
file(MAKE_DIRECTORY "${build_dir}/esp_err_codes")
# Build the header arguments for the extract script
# Always provide esp_err.h as a context header for ESP_ERR_*_BASE definitions
set(header_args "")
set(esp_err_h "${idf_path}/components/esp_common/include/esp_err.h")
set(context_args "")
set(need_context TRUE)
foreach(h ${header_files})
list(APPEND header_args "${h}")
if("${h}" STREQUAL "${esp_err_h}")
set(need_context FALSE)
endif()
endforeach()
if(need_context)
set(context_args "--context-headers" "${esp_err_h}")
endif()
# Step 1: Extract error codes from headers to CSV (build time)
set(extract_script "${idf_path}/tools/err_codes_extract.py")
add_custom_command(
OUTPUT "${csv_output}"
COMMAND ${python} "${extract_script}"
--headers ${header_args}
${context_args}
--base-path "${idf_path}"
--output "${csv_output}"
DEPENDS ${header_files} ${esp_err_h} "${extract_script}"
COMMENT "Extracting error codes for ${COMPONENT_NAME}"
VERBATIM
)
# Compute include-form headers for the generated C file
set(include_headers "")
foreach(h ${header_files})
# Convert full path to include-relative path
get_filename_component(h_name "${h}" NAME)
# Try to find 'include' in the path
string(FIND "${h}" "/include/" include_pos)
if(include_pos GREATER -1)
math(EXPR include_start "${include_pos} + 9") # length of "/include/"
string(SUBSTRING "${h}" ${include_start} -1 include_rel)
list(APPEND include_headers "${include_rel}")
else()
list(APPEND include_headers "${h_name}")
endif()
endforeach()
# Step 2: Generate C source from CSV (build time)
set(gen_script "${idf_path}/tools/err_codes_to_c.py")
set(header_flags "")
foreach(inc ${include_headers})
list(APPEND header_flags "--headers" "${inc}")
endforeach()
add_custom_command(
OUTPUT "${c_output}"
COMMAND ${python} "${gen_script}"
--csv "${csv_output}"
--output "${c_output}"
--component "${COMPONENT_NAME}"
${header_flags}
DEPENDS "${csv_output}" "${gen_script}"
COMMENT "Generating error code table for ${COMPONENT_NAME}"
VERBATIM
)
# Step 3: Add generated C source to the component
target_sources(${COMPONENT_LIB} PRIVATE "${c_output}")
# Step 4: Force-link the error codes symbol so it's not discarded
string(REPLACE "-" "_" safe_component "${COMPONENT_NAME}")
string(REPLACE "." "_" safe_component "${safe_component}")
# On macOS (Mach-O) the linker sees C symbols with a leading '_' prepended
# by the compiler. Pass the mangled name so -u resolves correctly.
# On Linux (ELF) no prefix is added, so use the C identifier as-is.
if(APPLE)
set(force_link_symbol "_esp_err_msg_${safe_component}_include")
else()
set(force_link_symbol "esp_err_msg_${safe_component}_include")
endif()
target_link_libraries(${COMPONENT_LIB} INTERFACE "-u ${force_link_symbol}")
endfunction()
+1
View File
@@ -51,6 +51,7 @@ if(NOT __idf_env_set)
include(gdbinit)
include(prefix_map)
include(openocd)
include(err_codes)
# ESP-IDF extra dependencies defined in tools/idf_extra_components.yml
if(WIN32)
+24 -4
View File
@@ -831,13 +831,29 @@ macro(project project_name)
COMMAND ${CMAKE_COMMAND} -E touch ${project_elf_src}
VERBATIM)
add_custom_target(_project_elf_src DEPENDS "${project_elf_src}")
# On the Linux (host) target the standard GNU ld processes static archives
# in a single left-to-right pass, which fails when component libraries (or
# their transitive dependencies such as the mbedtls sub-libraries) have
# circular symbol references. Wrap all archives in --start-group /
# --end-group so the linker re-scans until every symbol is resolved.
if(CONFIG_IDF_TARGET_LINUX AND NOT CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin")
string(CONCAT _link_exe_template
"<CMAKE_C_COMPILER> <FLAGS> <CMAKE_C_LINK_FLAGS> <LINK_FLAGS>"
" <OBJECTS> -o <TARGET>"
" -Wl,--start-group <LINK_LIBRARIES> -Wl,--end-group")
set(CMAKE_C_LINK_EXECUTABLE "${_link_exe_template}")
string(REPLACE "<CMAKE_C_COMPILER>" "<CMAKE_CXX_COMPILER>"
_link_exe_template "${_link_exe_template}")
string(REPLACE "<CMAKE_C_LINK_FLAGS>" "<CMAKE_CXX_LINK_FLAGS>"
_link_exe_template "${_link_exe_template}")
set(CMAKE_CXX_LINK_EXECUTABLE "${_link_exe_template}")
unset(_link_exe_template)
endif()
add_executable(${project_elf} "${project_elf_src}")
add_dependencies(${project_elf} _project_elf_src)
if(__PROJECT_GROUP_LINK_COMPONENTS)
target_link_libraries(${project_elf} PRIVATE "-Wl,--start-group")
endif()
if(CONFIG_IDF_TARGET_LINUX AND CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin")
# Compiling for the host, and the host is macOS, so the linker is Darwin LD.
# Note, when adding support for Clang and LLD based toolchain this check will
@@ -847,6 +863,10 @@ macro(project project_name)
set(linker_type "GNU")
endif()
if(__PROJECT_GROUP_LINK_COMPONENTS)
target_link_libraries(${project_elf} PRIVATE "-Wl,--start-group")
endif()
if(test_components)
if(linker_type STREQUAL "GNU")
target_link_libraries(${project_elf} PRIVATE "-Wl,--whole-archive")
+5
View File
@@ -32,6 +32,10 @@ include(${CMAKE_CURRENT_LIST_DIR}/../cmake/openocd.cmake)
# idf_build_generate_depgraph function.
include(${CMAKE_CURRENT_LIST_DIR}/../cmake/depgraph.cmake)
# The err_codes.cmake file from cmakev1 provides idf_define_esp_err_codes(),
# which generates per-component error code tables placed in a link-time array.
include(${CMAKE_CURRENT_LIST_DIR}/../cmake/err_codes.cmake)
include(component)
include(build)
include(kconfig)
@@ -42,6 +46,7 @@ include(ldgen)
include(dfu)
include(uf2)
include(size)
include(GetGitRevisionDescription)
# For backward compatibility, since externalproject_add is used by
# project_include.cmake in the bootloader component. The ExternalProject
+377
View File
@@ -0,0 +1,377 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
#
# Extract ESP_ERR_* error code definitions from C header files and output them as CSV.
# This script is used by the composable error code registration system.
#
# Usage:
# python err_codes_extract.py --headers file1.h file2.h --output errors.csv
# python err_codes_extract.py --search-dirs components/ --output errors.csv
#
import argparse
import csv
import fnmatch
import os
import re
import sys
# Directories to exclude from recursive search
EXCLUDE_SEARCH_DIRS = {'test_apps', 'unit-test-app', 'CMock', 'test'}
# Files to exclude
EXCLUDE_FILES: list[str] = []
class ErrCode:
"""Represents a single extracted error code definition."""
def __init__(
self,
name: str,
file: str,
value: int | None = None,
base_name: str = '',
base_offset: int = 0,
comment: str = '',
) -> None:
self.name = name
self.file = file
self.value = value # Resolved integer value, or None if unresolved
self.base_name = base_name # Name of base error if this is BASE + offset
self.base_offset = base_offset
self.comment = comment
def __repr__(self) -> str:
if self.value is not None:
return f'{self.name} = {self.value} (0x{self.value & 0xFFFFFFFF:x}) from {self.file}'
return f'{self.name} = {self.base_name}+{self.base_offset} from {self.file}'
def _parse_define_value(value_str: str) -> tuple[int | None, str, int]:
"""
Parse the value part of a #define.
Returns:
(resolved_value, base_name, base_offset)
If the value is a plain number, resolved_value is set and base_name is empty.
If the value references a base, resolved_value is None and base_name/base_offset are set.
"""
# Strip surrounding parentheses
value_str = value_str.strip()
m = re.match(r'^\((.+)\)$', value_str)
if m:
value_str = m.group(1).strip()
# Check for BASE + offset pattern
m = re.match(r'(\w+)\s*\+\s*(.+)', value_str)
if m:
base_name = m.group(1)
offset_str = m.group(2).strip()
# Try to parse offset as hex or decimal
offset = _parse_number(offset_str)
if offset is not None:
return (None, base_name, offset)
# offset might be another symbol — treat as unresolvable for now
return (None, base_name, 0)
# Try to parse as a plain number
num = _parse_number(value_str)
if num is not None:
return (num, '', 0)
# It might be a reference to another symbol (no offset)
m = re.match(r'^(\w+)$', value_str)
if m:
return (None, m.group(1), 0)
return (None, '', 0)
def _parse_number(s: str) -> int | None:
"""Parse a hexadecimal or decimal integer from a string."""
s = s.strip()
m = re.match(r'^0x([0-9A-Fa-f]+)$', s)
if m:
return int(m.group(1), 16)
m = re.match(r'^(-?[0-9]+)$', s)
if m:
return int(m.group(1), 10)
return None
def extract_from_file(filepath: str, rel_path: str) -> list[ErrCode]:
"""
Extract ESP_ERR_* defines from a single header file.
Args:
filepath: Absolute path to the file.
rel_path: Relative path for display/CSV output.
Returns:
List of ErrCode objects.
"""
results: list[ErrCode] = []
define_pattern = re.compile(
r'^\s*#\s*define\s+(ESP_ERR_\w+|ESP_OK\w*|ESP_FAIL)\s+(.*?)(?:\s*/\*[*!]?<?\s*(.*?)\s*\*/)?(?:\s*/{2,3}\s*!?<?\s*(.*))?$'
)
try:
with open(filepath, encoding='utf-8') as f:
for line in f:
line = line.rstrip('\n')
m = define_pattern.match(line)
if not m:
continue
name = m.group(1)
value_str = m.group(2).strip()
comment = (m.group(3) or m.group(4) or '').strip()
# Remove trailing inline comments from value_str
# e.g. "0x101 /*!< Out of memory */" -> "0x101"
comment_match = re.search(r'\s*/\*[*!]?<?\s*(.*?)\s*\*/', value_str)
if comment_match:
if not comment:
comment = comment_match.group(1).strip()
value_str = value_str[: comment_match.start()].strip()
# Handle multi-line comments that start with /*!< but don't close on same line
comment_match_open = re.search(r'\s*/\*[*!]?<?\s*(.*)', value_str)
if comment_match_open:
if not comment:
comment = comment_match_open.group(1).strip().rstrip(',')
value_str = value_str[: comment_match_open.start()].strip()
# Also handle // and /// comments at end
comment_match2 = re.search(r'\s*/{2,3}\s*!?<?\s*(.*)', value_str)
if comment_match2:
if not comment:
comment = comment_match2.group(1).strip()
value_str = value_str[: comment_match2.start()].strip()
resolved, base_name, base_offset = _parse_define_value(value_str)
results.append(
ErrCode(
name=name,
file=rel_path,
value=resolved,
base_name=base_name,
base_offset=base_offset,
comment=comment,
)
)
except UnicodeDecodeError:
print(f'Warning: Cannot decode {filepath}, skipping', file=sys.stderr)
return results
def resolve_error_codes(err_codes: list[ErrCode]) -> list[ErrCode]:
"""
Resolve error codes that depend on base codes.
Performs multiple passes to handle transitive dependencies.
Returns list of resolved ErrCode objects (with .value set).
Unresolvable codes are printed as warnings and excluded.
"""
# Build a lookup from name to resolved value
resolved: dict[str, int] = {}
unresolved: list[ErrCode] = []
for ec in err_codes:
if ec.value is not None:
resolved[ec.name] = ec.value
else:
unresolved.append(ec)
# Iteratively resolve
max_iterations = 10
for _ in range(max_iterations):
still_unresolved = []
for ec in unresolved:
if ec.base_name in resolved:
ec.value = resolved[ec.base_name] + ec.base_offset
resolved[ec.name] = ec.value
else:
still_unresolved.append(ec)
if len(still_unresolved) == len(unresolved):
break # No progress
unresolved = still_unresolved
for ec in unresolved:
print(f'Warning: Cannot resolve {ec.name} (depends on {ec.base_name}) in {ec.file}', file=sys.stderr)
return [ec for ec in err_codes if ec.value is not None]
def search_headers(search_dirs: list[str], base_path: str) -> list[str]:
"""
Recursively find all .h files in the given directories,
excluding test directories.
"""
headers: list[str] = []
for search_dir in search_dirs:
full_dir = os.path.join(base_path, search_dir) if not os.path.isabs(search_dir) else search_dir
for root, dirnames, filenames in os.walk(full_dir, topdown=True):
# Filter out excluded directories
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_SEARCH_DIRS]
for filename in fnmatch.filter(filenames, '*.h'):
headers.append(os.path.join(root, filename))
return headers
def extract_all(headers: list[str], base_path: str, context_headers: list[str] | None = None) -> list[ErrCode]:
"""
Extract and resolve error codes from a list of header files.
Args:
headers: Headers whose error codes will be emitted.
base_path: Base path for relative paths.
context_headers: Additional headers scanned only for base definitions
(e.g. ESP_ERR_*_BASE). Their codes are used for
resolution but are excluded from the final output.
"""
context_names: set = set()
all_codes: list[ErrCode] = []
# Scan context headers first — their defines help resolve bases
if context_headers:
for header in context_headers:
rel = os.path.relpath(header, base_path) if base_path else header
ctx_codes = extract_from_file(header, rel)
for ec in ctx_codes:
context_names.add(ec.name)
all_codes.extend(ctx_codes)
# Scan primary headers
for header in headers:
rel = os.path.relpath(header, base_path) if base_path else header
all_codes.extend(extract_from_file(header, rel))
resolved = resolve_error_codes(all_codes)
# Remove context-only codes from the output
if context_names:
resolved = [ec for ec in resolved if ec.name not in context_names]
return resolved
def validate_error_codes(err_codes: list[ErrCode]) -> list[str]:
"""
Validate extracted error codes and return a list of error messages.
Returns an empty list if all checks pass.
Checks performed:
- No two non-BASE error codes share the same numeric value.
- No duplicate names with conflicting values.
"""
errors: list[str] = []
# --- Check 1: duplicate values (skip _BASE codes, they are offset anchors) ---
value_to_codes: dict[int, list[ErrCode]] = {}
for ec in err_codes:
if ec.value is None or ec.name.endswith('_BASE'):
continue
value_to_codes.setdefault(ec.value, []).append(ec)
for value, codes in sorted(value_to_codes.items()):
# Deduplicate by name — same name from different files is OK
unique_names = {ec.name for ec in codes}
if len(unique_names) > 1:
names_str = ', '.join(f'{ec.name} ({ec.file})' for ec in codes)
errors.append(f'Duplicate value 0x{value & 0xFFFFFFFF:x} ({value}): {names_str}')
# --- Check 2: same name defined with different values ---
name_to_values: dict[str, list[ErrCode]] = {}
for ec in err_codes:
if ec.value is None:
continue
name_to_values.setdefault(ec.name, []).append(ec)
for name, codes in sorted(name_to_values.items()):
unique_values = {ec.value for ec in codes}
if len(unique_values) > 1:
defs_str = ', '.join(f'{ec.value} in {ec.file}' for ec in codes)
errors.append(f'Conflicting definitions for {name}: {defs_str}')
return errors
def write_csv(err_codes: list[ErrCode], output_path: str) -> None:
"""Write resolved error codes to a CSV file."""
# Deduplicate: keep the first occurrence of each name
seen = set()
unique_codes: list[ErrCode] = []
for ec in err_codes:
if ec.name not in seen:
seen.add(ec.name)
unique_codes.append(ec)
# Sort by value then name
unique_codes.sort(key=lambda e: (e.value if e.value is not None else 0, e.name))
with open(output_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerow(['name', 'value', 'file', 'comment'])
for ec in unique_codes:
writer.writerow([ec.name, ec.value, ec.file, ec.comment])
def main() -> None:
parser = argparse.ArgumentParser(description='Extract ESP error codes from header files and output as CSV')
parser.add_argument('--headers', nargs='*', default=[], help='Specific header files to process')
parser.add_argument(
'--context-headers',
nargs='*',
default=[],
help='Header files scanned only for base definitions (e.g. ESP_ERR_*_BASE). '
'Codes found here are used for resolution but not emitted.',
)
parser.add_argument(
'--search-dirs', nargs='*', default=[], help='Directories to search recursively for header files'
)
parser.add_argument(
'--base-path', default=None, help='Base path for computing relative paths (default: IDF_PATH or cwd)'
)
parser.add_argument('--output', required=True, help='Output CSV file path')
parser.add_argument(
'--validate',
action='store_true',
help='Validate error codes (fail on duplicate values or conflicting definitions)',
)
args = parser.parse_args()
base_path = args.base_path
if base_path is None:
base_path = os.environ.get('IDF_PATH', os.getcwd())
headers = list(args.headers)
if args.search_dirs:
headers.extend(search_headers(args.search_dirs, base_path))
if not headers:
print('Error: No header files specified. Use --headers or --search-dirs.', file=sys.stderr)
sys.exit(1)
context_headers = list(args.context_headers) if args.context_headers else None
err_codes = extract_all(headers, base_path, context_headers=context_headers)
write_csv(err_codes, args.output)
if args.validate:
validation_errors = validate_error_codes(err_codes)
if validation_errors:
print(f'\nValidation FAILED with {len(validation_errors)} error(s):', file=sys.stderr)
for err in validation_errors:
print(f' ERROR: {err}', file=sys.stderr)
sys.exit(1)
else:
print('Validation passed: no duplicate values or conflicting definitions.')
if __name__ == '__main__':
main()
+142
View File
@@ -0,0 +1,142 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
#
# Generate a C source file that registers error codes into a designated linker section.
# The generated file creates an array of esp_err_msg_t entries in the ".esp_err_msg_tbl"
# section, which is collected at link time by the linker script.
#
# Usage:
# python err_codes_to_c.py --csv errors.csv --output esp_err_codes_mycomp.c --component mycomp
#
import argparse
import csv
import datetime
import os
def read_csv(csv_path: str) -> list[tuple[str, int, str]]:
"""
Read error codes from CSV file.
Returns list of (name, value, comment) tuples.
"""
entries: list[tuple[str, int, str]] = []
with open(csv_path, encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
name = row['name']
value = int(row['value'])
comment = row.get('comment', '')
entries.append((name, value, comment))
return entries
def generate_c_source(entries: list[tuple[str, int, str]], component: str, headers: list[str]) -> str:
"""
Generate C source code that places error code entries into the
.esp_err_msg_tbl linker section.
"""
lines: list[str] = []
year = datetime.datetime.now().year
lines.append('/*')
lines.append(f' * SPDX-FileCopyrightText: {year} Espressif Systems (Shanghai) CO LTD')
lines.append(' *')
lines.append(' * SPDX-License-Identifier: Apache-2.0')
lines.append(' */')
lines.append('')
lines.append('// Auto-generated by err_codes_to_c.py — do not edit')
lines.append('')
lines.append('#include "esp_err.h"')
lines.append('#include "esp_err_msg.h"')
lines.append('#include "esp_attr.h"')
lines.append('')
# Include the headers that define the error codes
# Skip headers already included above
already_included = {'esp_err.h', 'esp_err_msg.h', 'esp_attr.h'}
for header in headers:
if header in already_included:
continue
lines.append(f'#if __has_include("{header}")')
lines.append(f'#include "{header}"')
lines.append('#endif')
lines.append('')
lines.append('#ifdef CONFIG_ESP_ERR_TO_NAME_LOOKUP')
lines.append('')
# Generate array entries with #ifdef guards
safe_component = component.replace('-', '_').replace('.', '_')
symbol_name = f'esp_err_msg_{safe_component}'
lines.append(f'// Force-link symbol for {component} error codes')
lines.append(f'const int {symbol_name}_include = 0;')
lines.append('')
if entries:
lines.append(f'static const esp_err_msg_t {symbol_name}[]')
lines.append(' PLACE_IN_SECTION("esp_err_msg_tbl") = {')
for name, value, comment in entries:
comment_str = f' /* {comment} */' if comment else ''
lines.append(f'#ifdef {name}')
lines.append(f' {{ {name}, "{name}" }},{comment_str}')
lines.append('#endif')
lines.append('};')
lines.append('')
lines.append('#endif // CONFIG_ESP_ERR_TO_NAME_LOOKUP')
lines.append('')
return '\n'.join(lines)
def _infer_include_path(file_path: str) -> str:
"""
Convert a file path like 'components/foo/include/bar/baz.h'
into an include path like 'bar/baz.h'.
"""
parts = file_path.replace('\\', '/').split('/')
try:
idx = parts.index('include')
return '/'.join(parts[idx + 1 :])
except ValueError:
return os.path.basename(file_path)
def main() -> None:
parser = argparse.ArgumentParser(
description='Generate C source file with error code registration via linker section'
)
parser.add_argument('--csv', required=True, help='Input CSV file with error codes (from err_codes_extract.py)')
parser.add_argument('--output', required=True, help='Output C source file path')
parser.add_argument('--component', required=True, help='Component name (used for symbol naming)')
parser.add_argument('--headers', nargs='*', default=[], help='Header files to #include (include-path form)')
args = parser.parse_args()
entries = read_csv(args.csv)
# Deduce headers from the CSV file paths if none specified
headers = list(args.headers)
if not headers:
seen_files = set()
with open(args.csv, encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
file_path = row.get('file', '')
if file_path and file_path not in seen_files:
seen_files.add(file_path)
inc = _infer_include_path(file_path)
if inc not in headers:
headers.append(inc)
source = generate_c_source(entries, args.component, headers)
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
with open(args.output, 'w', encoding='utf-8') as f:
f.write(source)
if __name__ == '__main__':
main()
+98
View File
@@ -0,0 +1,98 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
#
# Generate RST documentation from extracted error codes.
# This script takes a CSV file (produced by err_codes_extract.py) and generates
# an RST file suitable for inclusion in Sphinx documentation.
#
# Usage:
# python err_codes_to_rst.py --csv errors.csv --output error_codes.inc
# python err_codes_to_rst.py --search-dirs components/ --output error_codes.inc
#
import argparse
import csv
import os
import sys
# Allow importing err_codes_extract from same directory
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from err_codes_extract import extract_all # noqa: E402
from err_codes_extract import search_headers # noqa: E402
def read_csv_entries(csv_path: str) -> list[tuple[str, int, str]]:
"""Read error codes from a CSV file."""
entries: list[tuple[str, int, str]] = []
with open(csv_path, encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
name = row['name']
value = int(row['value'])
comment = row.get('comment', '')
entries.append((name, value, comment))
return entries
def generate_rst(entries: list[tuple[str, int, str]]) -> str:
"""Generate RST content from error code entries.
Output format matches the existing esp_err_defs.inc generated by
gen_esp_err_to_name.py so it can be used as a drop-in replacement
in the docs build (included via ``.. include-build-file::``).
"""
lines: list[str] = []
# Sort by value: negative values first (ascending), then non-negative (ascending)
sorted_entries = sorted(entries, key=lambda e: (e[1], e[0]))
for name, value, comment in sorted_entries:
line = f':c:macro:`{name}` '
if value > 0:
line += f'**(0x{value:x})**'
else:
line += f'({value:d})'
if comment:
line += f': {comment}'
lines.append(line)
lines.append('')
return '\n'.join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description='Generate RST documentation of ESP error codes')
parser.add_argument('--csv', default=None, help='Input CSV file with error codes (from err_codes_extract.py)')
parser.add_argument(
'--search-dirs', nargs='*', default=[], help='Directories to search recursively for header files'
)
parser.add_argument('--base-path', default=None, help='Base path for computing relative paths')
parser.add_argument('--output', required=True, help='Output RST file path')
args = parser.parse_args()
base_path = args.base_path
if base_path is None:
base_path = os.environ.get('IDF_PATH', os.getcwd())
if args.csv:
entries = read_csv_entries(args.csv)
elif args.search_dirs:
headers = search_headers(args.search_dirs, base_path)
err_codes = extract_all(headers, base_path)
entries = [(ec.name, ec.value, ec.comment) for ec in err_codes if ec.value is not None]
else:
print('Error: Specify either --csv or --search-dirs.', file=sys.stderr)
sys.exit(1)
rst_content = generate_rst(entries)
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
with open(args.output, 'w', encoding='utf-8') as f:
f.write(rst_content)
print(f'Generated {args.output} with {len(entries)} error codes')
if __name__ == '__main__':
main()
+35 -351
View File
@@ -2,305 +2,49 @@
#
# SPDX-FileCopyrightText: 2018-2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
#
# Generate RST documentation of ESP error codes.
#
# Previously this script also generated the C lookup table.
# That path has been replaced by the composable
# error-code registration system (link-time arrays + err_codes_extract.py).
# The RST generation is kept so the esp_docs Sphinx extension
# (esp_docs.idf_extensions.esp_err_definitions) continues to work
# unchanged.
import argparse
import builtins
import collections
import fnmatch
import functools
import os
import re
import textwrap
from typing import Any
import sys
from typing import TextIO
# ignore files and dirs: Please use tools/ci/esp_err_to_name_exceptions.txt to add exceptions
ignore_files: list = []
ignore_dirs: list = []
# macros from here have higher priorities in case of collisions
priority_headers = [os.path.join('components', 'esp_common', 'include', 'esp_err.h')]
# The following headers won't be included. This is useful if they are permanently included from esp_err_to_name.c.in.
dont_include = [os.path.join('soc', 'soc.h'), os.path.join('esp_err.h')]
# Don't search the following directories, e.g. test directories
exclude_search_dirs = ['test_apps', 'unit-test-app']
err_dict = collections.defaultdict(list) # identified errors are stored here; mapped by the error code
rev_err_dict = dict() # map of error string to error code
unproc_list = list() # errors with unknown codes which depend on other errors
# Allow importing err_codes_extract from the same directory
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from err_codes_extract import extract_all # noqa: E402
from err_codes_extract import search_headers # noqa: E402
class ErrItem:
def generate_rst_output(idf_path: str, fout: TextIO) -> None:
"""
Contains information about the error:
- name - error string
- file - relative path inside the IDF project to the file which defines this error
- include_as - (optional) overwrites the include determined from file
- comment - (optional) comment for the error
- rel_str - (optional) error string which is a base for the error
- rel_off - (optional) offset in relation to the base error
Generate RST output using the composable error code extraction system
(err_codes_extract.py). Produces the format expected by the
``.. include-build-file:: inc/esp_err_defs.inc`` directive.
"""
components_dir = os.path.join(idf_path, 'components')
headers = search_headers([components_dir], idf_path)
err_codes = extract_all(headers, idf_path)
entries = [(ec.name, ec.value, ec.comment) for ec in err_codes if ec.value is not None]
def __init__(
self,
name: str,
file: str,
include_as: Any | None = None,
comment: str = '',
rel_str: str = '',
rel_off: int = 0,
) -> None:
self.name = name
self.file = file
self.include_as = include_as
self.comment = comment
self.rel_str = rel_str
self.rel_off = rel_off
# Sort: negative values first (ascending), then non-negative (ascending)
entries.sort(key=lambda e: (e[1], e[0]))
def __str__(self) -> str:
ret = self.name + ' from ' + self.file
if self.rel_str != '':
ret += ' is (' + self.rel_str + ' + ' + str(self.rel_off) + ')'
if self.comment != '':
ret += ' // ' + self.comment
return ret
def __cmp__(self, other) -> int:
if self.file in priority_headers and other.file not in priority_headers:
return -1
elif self.file not in priority_headers and other.file in priority_headers:
return 1
base = '_BASE'
if self.file == other.file:
if self.name.endswith(base) and not other.name.endswith(base):
return 1
elif not self.name.endswith(base) and other.name.endswith(base):
return -1
self_key = self.file + self.name
other_key = other.file + other.name
if self_key < other_key:
return -1
elif self_key > other_key:
return 1
for name, value, comment in entries:
fout.write(f':c:macro:`{name}` ')
if value > 0:
fout.write(f'**(0x{value:x})**')
else:
return 0
class InputError(RuntimeError):
"""
Represents and error on the input
"""
def __init__(self, p: str, e: str) -> None:
super().__init__(f'{p}: {e}')
def process(line: str, idf_path: str, include_as: Any) -> None:
"""
Process a line of text from file idf_path (relative to IDF project).
Fills the global list unproc_list and dictionaries err_dict, rev_err_dict
"""
if idf_path.endswith('.c'):
# We would not try to include a C file
raise InputError(idf_path, f'This line should be in a header file: {line}')
words = re.split(r' +', line, 2)
# words[1] is the error name
# words[2] is the rest of the line (value, base + value, comment)
if len(words) < 3:
raise InputError(idf_path, f'Error at line {line}')
line = ''
todo_str = words[2]
comment = ''
# identify possible comment
m = re.search(r'/\*!<(.+?(?=\*/))', todo_str)
if m:
comment = m.group(1).strip()
todo_str = todo_str[: m.start()].strip() # keep just the part before the comment
# identify possible parentheses ()
m = re.search(r'\((.+)\)', todo_str)
if m:
todo_str = m.group(1) # keep what is inside the parentheses
# identify BASE error code, e.g. from the form BASE + 0x01
m = re.search(r'\s*(\w+)\s*\+(.+)', todo_str)
if m:
related = m.group(1) # BASE
todo_str = m.group(2) # keep and process only what is after "BASE +"
# try to match a hexadecimal number
m = re.search(r'0x([0-9A-Fa-f]+)', todo_str)
if m:
num = int(m.group(1), 16)
else:
# Try to match a decimal number. Negative value is possible for some numbers, e.g. ESP_FAIL
m = re.search(r'(-?[0-9]+)', todo_str)
if m:
num = int(m.group(1), 10)
elif re.match(r'\w+', todo_str):
# It is possible that there is no number, e.g. #define ERROR BASE
related = todo_str # BASE error
num = 0 # (BASE + 0)
else:
raise InputError(idf_path, f'Cannot parse line {line}')
try:
related
except NameError:
# The value of the error is known at this moment because it do not depends on some other BASE error code
err_dict[num].append(ErrItem(words[1], idf_path, include_as, comment))
rev_err_dict[words[1]] = num
else:
# Store the information available now and compute the error code later
unproc_list.append(ErrItem(words[1], idf_path, include_as, comment, related, num))
def process_remaining_errors() -> None:
"""
Create errors which could not be processed before because the error code
for the BASE error code wasn't known.
This works for sure only if there is no multiple-time dependency, e.g.:
#define BASE1 0
#define BASE2 (BASE1 + 10)
#define ERROR (BASE2 + 10) - ERROR will be processed successfully only if it processed later than BASE2
"""
for item in unproc_list:
if item.rel_str in rev_err_dict:
base_num = rev_err_dict[item.rel_str]
num = base_num + item.rel_off
err_dict[num].append(ErrItem(item.name, item.file, item.include_as, item.comment))
rev_err_dict[item.name] = num
else:
print(item.rel_str + ' referenced by ' + item.name + ' in ' + item.file + ' is unknown')
del unproc_list[:]
def path_to_include(path: str) -> str:
"""
Process the path (relative to the IDF project) in a form which can be used
to include in a C file. Using just the filename does not work all the
time because some files are deeper in the tree. This approach tries to
find an 'include' parent directory an include its subdirectories, e.g.
"components/XY/include/esp32/file.h" will be transported into "esp32/file.h"
So this solution works only works when the subdirectory or subdirectories
are inside the "include" directory. Other special cases need to be handled
here when the compiler gives an unknown header file error message.
"""
spl_path = path.split(os.sep)
try:
i = spl_path.index('include')
except ValueError:
# no include in the path -> use just the filename
return os.path.basename(path)
else:
return os.sep.join(spl_path[i + 1 :]) # subdirectories and filename in "include"
def print_warning(error_list: list, error_code: int) -> None:
"""
Print warning about errors with the same error code
"""
print(f'[WARNING] The following errors have the same code ({error_code}):')
for e in error_list:
print(f' {e}')
def max_string_width() -> int:
max_width = 0
for k in err_dict:
for e in err_dict[k]:
x = len(e.name)
if x > max_width:
max_width = x
return max_width
def generate_c_output(fin: TextIO, fout: TextIO) -> None:
"""
Writes the output to fout based on th error dictionary err_dict and
template file fin.
"""
# make includes unique by using a set
includes = set()
for k in err_dict:
for e in err_dict[k]:
if e.include_as:
includes.add(e.include_as)
else:
includes.add(path_to_include(e.file))
# The order in a set in non-deterministic therefore it could happen that the
# include order will be different in other machines and false difference
# in the output file could be reported. In order to avoid this, the items
# are sorted in a list.
include_list = list(includes)
include_list.sort()
max_width = max_string_width() + 17 + 1 # length of " ERR_TBL_IT()," with spaces is 17
max_decdig = max(len(str(k)) for k in err_dict)
for line in fin:
if re.match(r'@COMMENT@', line):
fout.write('//Do not edit this file because it is autogenerated by ' + os.path.basename(__file__) + '\n')
elif re.match(r'@HEADERS@', line):
for i in include_list:
if i not in dont_include:
fout.write('#if __has_include("' + i + '")\n#include "' + i + '"\n#endif\n')
elif re.match(r'@ERROR_ITEMS@', line):
last_file = ''
for k in sorted(err_dict.keys()):
if len(err_dict[k]) > 1:
err_dict[k].sort(key=functools.cmp_to_key(ErrItem.__cmp__))
print_warning(err_dict[k], k)
for e in err_dict[k]:
if e.file != last_file:
last_file = e.file
fout.write(f' // {last_file}\n')
table_line = (
(' ERR_TBL_IT(' + e.name + '), ').ljust(max_width) + '/* ' + str(k).rjust(max_decdig)
)
fout.write(f'# ifdef {e.name}\n')
fout.write(table_line)
hexnum_length = 0
if k > 0: # negative number and zero should be only ESP_FAIL and ESP_OK
hexnum = f' 0x{k:x}'
hexnum_length = len(hexnum)
fout.write(hexnum)
if e.comment != '':
if len(e.comment) < 50:
fout.write(f' {e.comment}')
else:
indent = ' ' * (len(table_line) + hexnum_length + 1)
w = textwrap.wrap(e.comment, width=120, initial_indent=indent, subsequent_indent=indent)
# this couldn't be done with initial_indent because there is no initial_width option
fout.write(f' {w[0].strip()}')
for i in range(1, len(w)):
fout.write(f'\n{w[i]}')
fout.write(' */\n# endif\n')
else:
fout.write(line)
def generate_rst_output(fout: TextIO) -> None:
for k in sorted(err_dict.keys()):
v = err_dict[k][0]
fout.write(f':c:macro:`{v.name}` ')
if k > 0:
fout.write(f'**(0x{k:x})**')
else:
fout.write(f'({k:d})')
if len(v.comment) > 0:
fout.write(f': {v.comment}')
fout.write(f'({value:d})')
if comment:
fout.write(f': {comment}')
fout.write('\n\n')
@@ -310,72 +54,12 @@ def main() -> None:
else:
idf_path = os.path.realpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
parser = argparse.ArgumentParser(description='ESP32 esp_err_to_name lookup generator for esp_err_t')
parser.add_argument(
'--c_input',
help='Path to the esp_err_to_name.c.in template input.',
default=idf_path + '/components/esp_common/src/esp_err_to_name.c.in',
)
parser.add_argument(
'--c_output',
help='Path to the esp_err_to_name.c output.',
default=idf_path + '/components/esp_common/src/esp_err_to_name.c',
)
parser.add_argument('--rst_output', help='Generate .rst output and save it into this file')
parser.add_argument('--exceptions_file', default=idf_path + '/tools/ci/esp_err_to_name_exceptions.txt')
parser = argparse.ArgumentParser(description='ESP-IDF error code RST documentation generator')
parser.add_argument('--rst_output', required=True, help='Generate .rst output and save it into this file')
args = parser.parse_args()
include_as_pattern = re.compile(rf'\s*//\s*{os.path.basename(__file__)}: [^"]* "([^"]+)"')
define_pattern = re.compile(r'\s*#define\s+(ESP_ERR_|ESP_OK|ESP_FAIL)')
if args.exceptions_file and os.path.exists(args.exceptions_file):
with builtins.open(args.exceptions_file, encoding='utf-8') as exceptions_file:
for line in exceptions_file:
entry = line.strip()
if not entry or entry.startswith('#'):
continue
if entry.endswith('/'):
ignore_dirs.append(entry.rstrip('/'))
else:
ignore_files.append(entry)
for root, dirnames, filenames in os.walk(os.path.join(idf_path, 'components'), topdown=True):
# When topdown is True, we can modify the dirnames list in-place
# walk() will only recurse into the subdirectories whose names remain in dirnames
dirnames[:] = [d for d in dirnames if d not in exclude_search_dirs]
for filename in fnmatch.filter(filenames, '*.[ch]'):
full_path = os.path.join(root, filename)
path_in_idf = os.path.relpath(full_path, idf_path)
if path_in_idf in ignore_files or any(path_in_idf.startswith(d) for d in ignore_dirs):
continue
with builtins.open(full_path, encoding='utf-8') as f:
try:
include_as = None
for line in f:
line = line.strip()
m = include_as_pattern.search(line)
if m:
include_as = m.group(1)
# match also ESP_OK and ESP_FAIL because some of ESP_ERRs are referencing them
elif define_pattern.match(line):
try:
process(line, path_in_idf, include_as)
except InputError as e:
print(e)
except UnicodeDecodeError:
raise ValueError(f'The encoding of {path_in_idf} is not Unicode.')
process_remaining_errors()
if args.rst_output is not None:
with builtins.open(args.rst_output, 'w', encoding='utf-8') as fout:
generate_rst_output(fout)
else:
with (
builtins.open(args.c_input, encoding='utf-8') as fin,
builtins.open(args.c_output, 'w', encoding='utf-8') as fout,
):
generate_c_output(fin, fout)
with open(args.rst_output, 'w', encoding='utf-8') as fout:
generate_rst_output(idf_path, fout)
if __name__ == '__main__':
@@ -16,6 +16,10 @@ tools/test_apps/build_system/embed_test:
temporary: false
reason: Hardware independent feature, no need to test on all targets
tools/test_apps/build_system/err_codes_check:
enable:
- if: IDF_TARGET in ["esp32", "linux"]
tools/test_apps/build_system/external_project:
enable:
- if: IDF_TARGET in ["esp32", "esp32c3"]
@@ -0,0 +1,14 @@
# The following lines of boilerplate have to be in your project's
# CMakeLists in this exact order for cmake to work correctly
cmake_minimum_required(VERSION 3.22)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
# On Linux/macOS, limit to components that support the host target.
# The generated test uses #ifdef guards, so error codes from excluded
# components are simply skipped.
if(IDF_TARGET STREQUAL "linux")
set(COMPONENTS main)
endif()
project(err_codes_check)
@@ -0,0 +1,25 @@
| Supported Targets | ESP32 | Linux |
| ----------------- | ----- | ----- |
# Error Codes Registration Check
This test app verifies that all `ESP_ERR_*` error codes defined across ESP-IDF
components are correctly registered via `idf_define_esp_err_codes()` and can be
resolved by `esp_err_to_name()`.
## How it works
1. At CMake configure time, `err_codes_extract.py` scans all ESP-IDF component
headers to collect every `ESP_ERR_*` define.
2. A test source file is generated that calls `esp_err_to_name()` for each
collected error code and asserts the result matches the expected name.
3. The app is built and run (e.g., in QEMU or on hardware).
## Building and running
```bash
cd tools/test_apps/build_system/err_codes_check
idf.py set-target esp32
idf.py build
# Run in QEMU or flash to hardware
```
@@ -0,0 +1,78 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
#
# Generate a C test source file that validates all ESP-IDF error codes
# are correctly registered and resolvable via esp_err_to_name().
#
import csv
import os
import sys
def generate_test_source(csv_path: str, output_path: str) -> None:
"""Generate a C source file that tests all error codes."""
entries = []
with open(csv_path, encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
name = row['name']
value = int(row['value'])
entries.append((name, value))
lines = []
lines.append('/*')
lines.append(' * Auto-generated by gen_err_codes_test.py — do not edit')
lines.append(' */')
lines.append('')
lines.append('#include <stdio.h>')
lines.append('#include <string.h>')
lines.append('#include "esp_err.h"')
lines.append('')
lines.append('/* Forward declaration */')
lines.append('int check_err_code(esp_err_t code, const char *expected_name);')
lines.append('')
lines.append('int check_err_code(esp_err_t code, const char *expected_name)')
lines.append('{')
lines.append(' const char *actual = esp_err_to_name(code);')
lines.append(' if (strcmp(actual, expected_name) != 0) {')
lines.append(' printf("FAIL: esp_err_to_name(0x%x) = \\"%s\\", expected \\"%s\\"\\n",')
lines.append(' (unsigned)code, actual, expected_name);')
lines.append(' return 1;')
lines.append(' }')
lines.append(' return 0;')
lines.append('}')
lines.append('')
lines.append('int err_codes_check_all(void)')
lines.append('{')
lines.append(' int failures = 0;')
for name, value in entries:
# Use #ifdef guards as the error code may not be defined for all targets
lines.append(f'#ifdef {name}')
lines.append(f' failures += check_err_code({name}, "{name}");')
lines.append('#endif')
lines.append('')
lines.append(' if (failures == 0) {')
lines.append(f' printf("All {len(entries)} error code checks passed.\\n");')
lines.append(' } else {')
lines.append(f' printf("%d of {len(entries)} error code checks failed.\\n", failures);')
lines.append(' }')
lines.append(' return failures;')
lines.append('}')
lines.append('')
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(lines))
print(f'Generated {output_path} with {len(entries)} error code checks')
if __name__ == '__main__':
if len(sys.argv) != 3:
print(f'Usage: {sys.argv[0]} <input.csv> <output.c>', file=sys.stderr)
sys.exit(1)
generate_test_source(sys.argv[1], sys.argv[2])
@@ -0,0 +1,33 @@
set(idf_path "$ENV{IDF_PATH}")
set(project_dir "${CMAKE_CURRENT_LIST_DIR}/..")
# Step 1: Extract all error codes from the entire ESP-IDF components directory
set(all_err_csv "${CMAKE_CURRENT_BINARY_DIR}/all_esp_err_codes.csv")
execute_process(
COMMAND ${PYTHON} "${idf_path}/tools/err_codes_extract.py"
--search-dirs "components"
--base-path "${idf_path}"
--output "${all_err_csv}"
RESULT_VARIABLE extract_result
OUTPUT_VARIABLE extract_output
ERROR_VARIABLE extract_error
)
if(NOT extract_result EQUAL 0)
message(FATAL_ERROR "err_codes_extract.py failed: ${extract_error}")
endif()
# Step 2: Generate the test source file from the CSV
set(test_src "${CMAKE_CURRENT_BINARY_DIR}/err_codes_test_cases.c")
execute_process(
COMMAND ${PYTHON} "${project_dir}/gen_err_codes_test.py"
"${all_err_csv}" "${test_src}"
RESULT_VARIABLE gen_result
OUTPUT_VARIABLE gen_output
ERROR_VARIABLE gen_error
)
if(NOT gen_result EQUAL 0)
message(FATAL_ERROR "gen_err_codes_test.py failed: ${gen_error}")
endif()
idf_component_register(SRCS "test_main.c" "${test_src}"
INCLUDE_DIRS ".")
@@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdio.h>
#include "esp_err.h"
/* Defined in the generated err_codes_test_cases.c */
extern int err_codes_check_all(void);
void app_main(void)
{
printf("Starting error codes registration check...\n");
int failures = err_codes_check_all();
if (failures == 0) {
printf("SUCCESS: All error codes are correctly registered.\n");
} else {
printf("FAILURE: %d error codes are not correctly registered.\n", failures);
}
}
@@ -0,0 +1,25 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
import pytest
from pytest_embedded_idf.dut import IdfDut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@pytest.mark.qemu
@idf_parametrize('target', ['esp32'], indirect=['target'])
def test_err_codes_check(dut: IdfDut) -> None:
dut.expect_exact('SUCCESS: All error codes are correctly registered.', timeout=120)
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_err_codes_check_linux(dut: IdfDut) -> None:
dut.expect_exact('SUCCESS: All error codes are correctly registered.', timeout=120)
@pytest.mark.host_test
@pytest.mark.macos
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_err_codes_check_macos(dut: IdfDut) -> None:
dut.expect_exact('SUCCESS: All error codes are correctly registered.', timeout=120)