summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
authorHiFiPhile <[email protected]>2026-06-22 21:30:58 +0200
committerHiFiPhile <[email protected]>2026-06-22 21:30:58 +0200
commit693cdce08e14833f26f4e8a1f26e4fd546be4c35 (patch)
tree7667d2223dc32d9f21b5b60ab200e64ed46e3e20 /tools
parent41e9eaa65a935136085d78ec4b99c81ff991b560 (diff)
parentcd3561bf158afd5a5718904b8139a338d1e3b67c (diff)
Merge remote-tracking branch 'tinyusb/master' into pr-osal-spin-deinit
Signed-off-by: HiFiPhile <[email protected]>
Diffstat (limited to 'tools')
-rwxr-xr-xtools/build.py200
-rw-r--r--tools/codespell/ignore-words.txt23
-rw-r--r--tools/file2carray.py3
-rwxr-xr-xtools/gen_doc.py2
-rwxr-xr-xtools/gen_presets.py38
-rwxr-xr-xtools/get_deps.py110
-rwxr-xr-xtools/iar_gen.py2
-rw-r--r--tools/iar_template.ipcf9
-rwxr-xr-xtools/make_release.py20
-rw-r--r--tools/metrics.py662
-rw-r--r--tools/metrics_compare_base.py350
11 files changed, 1289 insertions, 130 deletions
diff --git a/tools/build.py b/tools/build.py
index ce4d0ef1a..86bc30d28 100755
--- a/tools/build.py
+++ b/tools/build.py
@@ -5,6 +5,7 @@ import os
import sys
import time
import subprocess
+import shlex
from pathlib import Path
from multiprocessing import Pool
@@ -25,13 +26,35 @@ build_status = [STATUS_OK, STATUS_FAILED, STATUS_SKIPPED]
verbose = False
parallel_jobs = os.cpu_count()
+# CI board control lists (used when running under CI)
+ci_skip_boards = {
+ 'rp2040': [
+ 'adafruit_feather_rp2040_usb_host',
+ 'adafruit_fruit_jam',
+ 'adafruit_metro_rp2350',
+ 'feather_rp2040_max3421',
+ 'pico_sdk',
+ 'raspberry_pi_pico_w',
+ ],
+}
+
+ci_preferred_boards = {
+ 'samd2x_l2x': ['metro_m0_express'],
+ 'samd5x_e5x': ['metro_m4_express'],
+ 'stm32h7': ['stm32h743eval']
+}
+
+
# -----------------------------
# Helper
# -----------------------------
def run_cmd(cmd):
- #print(cmd)
- r = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
- title = f'Command Error: {cmd}'
+ if isinstance(cmd, str):
+ raise TypeError("run_cmd expects a list/tuple of args, not a string")
+ args = cmd
+ cmd_display = " ".join(args)
+ r = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ title = f'Command Error: {cmd_display}'
if r.returncode != 0:
# print build output if failed
if os.getenv('GITHUB_ACTIONS'):
@@ -42,7 +65,7 @@ def run_cmd(cmd):
print(title)
print(r.stdout.decode("utf-8"))
elif verbose:
- print(cmd)
+ print(cmd_display)
print(r.stdout.decode("utf-8"))
return r
@@ -74,24 +97,22 @@ def get_examples(family):
return all_examples
-def print_build_result(board, example, status, duration):
+def print_build_result(board, build_target, status, duration):
if isinstance(duration, (int, float)):
duration = "{:.2f}s".format(duration)
- print(build_format.format(board, example, build_status[status], duration))
+ print(build_format.format(board, build_target, build_status[status], duration))
# -----------------------------
# CMake
# -----------------------------
-def cmake_board(board, build_args, build_flags_on):
+def cmake_board(board, build_args, build_name, build_cflags, build_targets):
ret = [0, 0, 0]
start_time = time.monotonic()
- build_dir = f'cmake-build/cmake-build-{board}'
- build_flags = ''
- if len(build_flags_on) > 0:
- build_flags = ' '.join(f'-D{flag}=1' for flag in build_flags_on)
- build_flags = f'-DCFLAGS_CLI="{build_flags}"'
- build_dir += '-f1_' + '_'.join(build_flags_on)
+ build_dir = f'cmake-build/cmake-build-{build_name or board}'
+ build_flags = []
+ if build_cflags:
+ build_flags.append('-DCFLAGS_CLI=' + ' '.join(build_cflags))
family = find_family(board)
if family == 'espressif':
@@ -101,50 +122,46 @@ def cmake_board(board, build_args, build_flags_on):
if build_utils.skip_example(example, board):
ret[2] += 1
else:
- rcmd = run_cmd(f'idf.py -C examples/{example} -B {build_dir}/{example} -G Ninja '
- f'-DBOARD={board} {build_flags} build')
+ rcmd = run_cmd([
+ 'idf.py', '-C', f'examples/{example}', '-B', f'{build_dir}/{example}', '-GNinja',
+ f'-DBOARD={board}', *build_flags, 'build'
+ ])
ret[0 if rcmd.returncode == 0 else 1] += 1
else:
- rcmd = run_cmd(f'cmake examples -B {build_dir} -G Ninja -DBOARD={board} -DCMAKE_BUILD_TYPE=MinSizeRel '
- f'{build_args} {build_flags}')
+ rcmd = run_cmd(['cmake', 'examples', '-B', build_dir, '-GNinja',
+ f'-DBOARD={board}', '-DCMAKE_BUILD_TYPE=MinSizeRel', '-DLINKERMAP_OPTION=-q -f tinyusb/src',
+ *build_args, *build_flags])
if rcmd.returncode == 0:
- cmd = f"cmake --build {build_dir}"
- njobs = parallel_jobs
-
- # circleci docker return $nproc as 36 core, limit parallel according to resource class.
- # Required for IAR, also prevent crashed/killed by docker
- if os.getenv('CIRCLECI'):
- resource_class = { 'small': 1, 'medium': 2, 'medium+': 3, 'large': 4 }
- for rc in resource_class:
- if rc in os.getenv('CIRCLE_JOB'):
- njobs = resource_class[rc]
- break
- cmd += f' --parallel {njobs}'
- rcmd = run_cmd(cmd)
+ cmd = ["cmake", "--build", build_dir, '--parallel', str(parallel_jobs)]
+ for target in build_targets:
+ rcmd = run_cmd(cmd + ['--target', target])
+ if rcmd.returncode != 0:
+ break
ret[0 if rcmd.returncode == 0 else 1] += 1
- example = 'all'
- print_build_result(board, example, 0 if ret[1] == 0 else 1, time.monotonic() - start_time)
+ print_build_result(board, ','.join(build_targets), 0 if ret[1] == 0 else 1, time.monotonic() - start_time)
return ret
# -----------------------------
# Make
# -----------------------------
-def make_one_example(example, board, make_option):
+def make_one_example(example, board, make_option, build_targets):
# Check if board is skipped
if build_utils.skip_example(example, board):
print_build_result(board, example, 2, '-')
r = 2
else:
start_time = time.monotonic()
- # skip -j for circleci
- if not os.getenv('CIRCLECI'):
- make_option += ' -j'
- make_cmd = f"make -C examples/{example} BOARD={board} {make_option}"
- # run_cmd(f"{make_cmd} clean")
- build_result = run_cmd(f"{make_cmd} all")
- r = 0 if build_result.returncode == 0 else 1
+ make_cmd = ["make", "-C", f"examples/{example}", f"BOARD={board}", '-j', str(parallel_jobs)]
+ if make_option:
+ make_cmd += shlex.split(make_option)
+ r = 0
+ for target in build_targets:
+ build_result = run_cmd(make_cmd + [target])
+ if build_result.returncode != 0:
+ r = 1
+ break
print_build_result(board, example, r, time.monotonic() - start_time)
ret = [0, 0, 0]
@@ -152,7 +169,7 @@ def make_one_example(example, board, make_option):
return ret
-def make_board(board, build_args):
+def make_board(board, build_args, build_targets):
print(build_separator)
family = find_family(board);
all_examples = get_examples(family)
@@ -163,7 +180,7 @@ def make_board(board, build_args):
final_status = 2
else:
with Pool(processes=os.cpu_count()) as pool:
- pool_args = list((map(lambda e, b=board, o=f"{build_args}": [e, b, o], all_examples)))
+ pool_args = list((map(lambda e, b=board, o=f"{build_args}", t=build_targets: [e, b, o, t], all_examples)))
r = pool.starmap(make_one_example, pool_args)
# sum all element of same index (column sum)
ret = list(map(sum, list(zip(*r))))
@@ -175,45 +192,58 @@ def make_board(board, build_args):
# -----------------------------
# Build Family
# -----------------------------
-def build_boards_list(boards, build_defines, build_system, build_flags_on):
+def build_boards_list(boards, build_defines, build_system, build_name, build_cflags, build_targets):
ret = [0, 0, 0]
for b in boards:
r = [0, 0, 0]
if build_system == 'cmake':
- build_args = ' '.join(f'-D{d}' for d in build_defines)
- r = cmake_board(b, build_args, build_flags_on)
+ build_args = [f'-D{d}' for d in build_defines]
+ r = cmake_board(b, build_args, build_name, build_cflags, build_targets)
elif build_system == 'make':
build_args = ' '.join(f'{d}' for d in build_defines)
- r = make_board(b, build_args)
+ r = make_board(b, build_args, build_targets)
ret[0] += r[0]
ret[1] += r[1]
ret[2] += r[2]
return ret
-def build_family(family, build_defines, build_system, build_flags_on, one_per_family, boards):
- skip_ci = ['pico_sdk']
+def get_family_boards(family, one_random, one_first):
+ """Get list of boards for a family.
+
+ Args:
+ family: Family name
+ one_random: If True, return only one random board
+ one_first: If True, return only the first board (alphabetical)
+
+ Returns:
+ List of board names
+ """
+ skip_list = []
+ preferred_list = []
if os.getenv('GITHUB_ACTIONS') or os.getenv('CIRCLECI'):
- skip_ci_file = Path(f"hw/bsp/{family}/skip_ci.txt")
- if skip_ci_file.exists():
- skip_ci = skip_ci_file.read_text().split()
+ skip_list = ci_skip_boards.get(family, [])
+ preferred_list = ci_preferred_boards.get(family, [])
+
all_boards = []
for entry in os.scandir(f"hw/bsp/{family}/boards"):
- if entry.is_dir() and not entry.name in skip_ci:
+ if entry.is_dir() and entry.name not in skip_list:
all_boards.append(entry.name)
+ if not all_boards:
+ print(f"No boards found for family '{family}'")
+ return []
all_boards.sort()
- ret = [0, 0, 0]
- # If only-one flag is set, select one random board
- if one_per_family:
- for b in boards:
- # skip if -b already specify one in this family
- if find_family(b) == family:
- return ret
- all_boards = [random.choice(all_boards)]
+ # If only-one flags are set, honor select list first, then pick first or random
+ if one_first or one_random:
+ if preferred_list:
+ return [preferred_list[0]]
+ if one_first:
+ return [all_boards[0]]
+ if one_random:
+ return [random.choice(all_boards)]
- ret = build_boards_list(all_boards, build_defines, build_system, build_flags_on)
- return ret
+ return all_boards
# -----------------------------
@@ -229,9 +259,17 @@ def main():
parser.add_argument('-t', '--toolchain', default='gcc', help='Toolchain to use, default is gcc')
parser.add_argument('-s', '--build-system', default='cmake', help='Build system to use, default is cmake')
parser.add_argument('-D', '--define-symbol', action='append', default=[], help='Define to pass to build system')
- parser.add_argument('-f1', '--build-flags-on', action='append', default=[], help='Build flag to pass to build system')
- parser.add_argument('-1', '--one-per-family', action='store_true', default=False, help='Build only one random board inside a family')
+ parser.add_argument('--build-name', default=None,
+ help='Override build dir name (cmake-build-<name>); default is the board name. Used for HIL variants.')
+ parser.add_argument('--cflag', action='append', default=[],
+ help='Raw compiler flag appended to CFLAGS_CLI, e.g. --cflag=-DCFG_TUD_DWC2_DMA_ENABLE=1 (repeatable)')
+ parser.add_argument('--one-random', action='store_true', default=False,
+ help='Build only one random board of each specified family')
+ parser.add_argument('--one-first', action='store_true', default=False,
+ help='Build only the first board (alphabetical) of each specified family')
parser.add_argument('-j', '--jobs', type=int, default=os.cpu_count(), help='Number of jobs to run in parallel')
+ parser.add_argument('-T', '--target', action='append', default=[],
+ help='Build target to use, may be specified multiple times (default: all)')
parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output')
args = parser.parse_args()
@@ -240,8 +278,11 @@ def main():
toolchain = args.toolchain
build_system = args.build_system
build_defines = args.define_symbol
- build_flags_on = args.build_flags_on
- one_per_family = args.one_per_family
+ build_name = args.build_name
+ build_cflags = args.cflag
+ one_random = args.one_random
+ one_first = args.one_first
+ build_targets = args.target if args.target else ['all']
verbose = args.verbose
parallel_jobs = args.jobs
@@ -251,12 +292,17 @@ def main():
print("Please specify families or board to build")
return 1
+ # --build-name renames the single shared build dir, so building more than one
+ # board with it would clobber/mix artifacts
+ if build_name and (len(families) > 0 or len(boards) != 1):
+ print("--build-name requires exactly one board (-b) and no families")
+ return 1
+
print(build_separator)
- print(build_format.format('Board', 'Example', '\033[39mResult\033[0m', 'Time'))
+ print(build_format.format('Board', 'Target', '\033[39mResult\033[0m', 'Time'))
total_time = time.monotonic()
- result = [0, 0, 0]
- # build families
+ # get all families
all_families = []
if 'all' in families:
for entry in os.scandir("hw/bsp"):
@@ -266,23 +312,19 @@ def main():
all_families = list(families)
all_families.sort()
- # succeeded, failed, skipped
+ # get boards from families and append to boards list
+ all_boards = list(boards)
for f in all_families:
- r = build_family(f, build_defines, build_system, build_flags_on, one_per_family, boards)
- result[0] += r[0]
- result[1] += r[1]
- result[2] += r[2]
+ all_boards.extend(get_family_boards(f, one_random, one_first))
- # build boards
- r = build_boards_list(boards, build_defines, build_system, build_flags_on)
- result[0] += r[0]
- result[1] += r[1]
- result[2] += r[2]
+ # build all boards
+ result = build_boards_list(all_boards, build_defines, build_system, build_name, build_cflags, build_targets)
total_time = time.monotonic() - total_time
print(build_separator)
print(f"Build Summary: {result[0]} {STATUS_OK}, {result[1]} {STATUS_FAILED} and took {total_time:.2f}s")
print(build_separator)
+
return result[1]
diff --git a/tools/codespell/ignore-words.txt b/tools/codespell/ignore-words.txt
index 957cbd86b..0b1aa284a 100644
--- a/tools/codespell/ignore-words.txt
+++ b/tools/codespell/ignore-words.txt
@@ -1,14 +1,17 @@
-synopsys
-sie
-tre
-thre
-hsi
-fro
-dout
-mot
-te
attch
+busses
+dout
endianess
+fro
+hsi
+inout
+mot
+ore
pris
-busses
+ptd
ser
+sie
+synopsys
+te
+thre
+tre
diff --git a/tools/file2carray.py b/tools/file2carray.py
index abfb4e21b..7150364bf 100644
--- a/tools/file2carray.py
+++ b/tools/file2carray.py
@@ -35,7 +35,8 @@ def main():
fout_name = fin_name + '.h'
with open(fout_name, 'w') as fout:
print(f"Converting {fin_name} to {fout_name}")
- fout.write(f'const size_t bindata_len = {len(contents)};\n')
+ fout.write(f'enum {{ BINDATA_LEN = {len(contents)} }};\n')
+ fout.write(f'const size_t bindata_len = BINDATA_LEN;\n')
fout.write(f'const uint8_t bindata[] __attribute__((aligned(16))) = {{')
print_carray(fout, contents)
fout.write('};\n')
diff --git a/tools/gen_doc.py b/tools/gen_doc.py
index ab07bc116..3920531d5 100755
--- a/tools/gen_doc.py
+++ b/tools/gen_doc.py
@@ -23,7 +23,7 @@ def gen_deps_doc():
Dependencies
************
-MCU low-level peripheral driver and external libraries for building TinyUSB examples
+MCU low-level peripheral drivers and external libraries for building TinyUSB examples
{tabulate(df, headers="keys", tablefmt='rst')}
"""
diff --git a/tools/gen_presets.py b/tools/gen_presets.py
index 94b8d16b0..6f32976a7 100755
--- a/tools/gen_presets.py
+++ b/tools/gen_presets.py
@@ -5,13 +5,20 @@ from pathlib import Path
def main():
board_list = []
+ board_list_esp = []
- # Find all board.cmake files
+ # Find all board.cmake files, exclude espressif
for root, dirs, files in os.walk("hw/bsp"):
for file in files:
- if file == "board.cmake":
+ if file == "board.cmake" and "espressif" not in root:
board_list.append(os.path.basename(root))
+ # Find all espressif boards
+ for root, dirs, files in os.walk("hw/bsp/espressif"):
+ for file in files:
+ if file == "board.cmake":
+ board_list_esp.append(os.path.basename(root))
+
print('Generating presets for the following boards:')
print(board_list)
@@ -25,12 +32,21 @@ def main():
"hidden": True,
"description": r"Configure preset for the ${presetName} board",
"generator": "Ninja Multi-Config",
- "binaryDir": r"${sourceDir}/build/${presetName}",
+ "binaryDir": r"${sourceDir}/cmake-build-${presetName}",
"cacheVariables": {
"CMAKE_DEFAULT_BUILD_TYPE": "RelWithDebInfo",
"BOARD": r"${presetName}"
+ }},
+ {"name": "default single config",
+ "hidden": True,
+ "description": r"Configure preset for the ${presetName} board",
+ "generator": "Ninja",
+ "binaryDir": r"${sourceDir}/cmake-build-${presetName}",
+ "cacheVariables": {
+ "BOARD": r"${presetName}"
}}]
+ # Add non-espressif boards
presets['configurePresets'].extend(
sorted(
[
@@ -43,6 +59,22 @@ def main():
)
)
+ # Add espressif boards with single config generator
+ presets['configurePresets'].extend(
+ sorted(
+ [
+ {
+ 'name': board,
+ 'inherits': 'default single config'
+ }
+ for board in board_list_esp
+ ], key=lambda x: x['name']
+ )
+ )
+
+ # Combine all boards
+ board_list.extend(board_list_esp)
+
# Build presets
# no inheritance since 'name' doesn't support macro expansion
presets['buildPresets'] = sorted(
diff --git a/tools/get_deps.py b/tools/get_deps.py
index 36ed98a62..ebbf9b871 100755
--- a/tools/get_deps.py
+++ b/tools/get_deps.py
@@ -8,12 +8,21 @@ from multiprocessing import Pool
# Mandatory Dependencies that is always fetched
# path, url, commit, family (Alphabet sorted by path)
deps_mandatory = {
+ 'lib/fatfs': ['https://github.com/abbrev/fatfs.git',
+ '30ca13c62615df0d2e9104ab41256985b96590c1',
+ 'all'],
'lib/FreeRTOS-Kernel': ['https://github.com/FreeRTOS/FreeRTOS-Kernel.git',
- 'cc0e0707c0c748713485b870bb980852b210877f',
+ '9b777ae5c5b8e9e456065a00294d1e5f5f9facf5',
'all'],
'lib/lwip': ['https://github.com/lwip-tcpip/lwip.git',
'159e31b689577dbf69cf0683bbaffbd71fa5ee10',
'all'],
+ 'lib/threadx': ['https://github.com/eclipse-threadx/threadx.git',
+ '4b6e8100d932a3a67b34c6eb17f84f3bffb9e2ae',
+ 'all'],
+ 'tools/linkermap': ['https://github.com/hathach/linkermap.git',
+ '8e1f440fa15c567aceb5aa0d14f6d18c329cc67f',
+ 'all'],
'tools/uf2': ['https://github.com/microsoft/uf2.git',
'c594542b2faa01cc33a2b97c9fbebc38549df80a',
'all'],
@@ -29,8 +38,8 @@ deps_optional = {
'b20b398d3e5e2007594e54a74ba3d2a2e50ddd75',
'maxim'],
'hw/mcu/bridgetek/ft9xx/ft90x-sdk': ['https://github.com/BRTSG-FOSS/ft90x-sdk.git',
- '91060164afe239fcb394122e8bf9eb24d3194eb1',
- 'brtmm90x'],
+ '03f74eac84645178fdde7f2e5ca9acdcb7bd9dcd',
+ 'ft9xx'],
'hw/mcu/broadcom': ['https://github.com/adafruit/broadcom-peripherals.git',
'08370086080759ed54ac1136d62d2ad24c6fa267',
'broadcom_32bit broadcom_64bit'],
@@ -42,22 +51,40 @@ deps_optional = {
'xmc4000'],
'hw/mcu/microchip': ['https://github.com/hathach/microchip_driver.git',
'9e8b37e307d8404033bb881623a113931e1edf27',
- 'sam3x samd11 samd21 samd51 samd5x_e5x same5x same7x saml2x samg'],
+ 'sam3x samd11 samd21 samd51 samd5x_e5x same5x same7x samd2x_l2x samg'],
'hw/mcu/mindmotion/mm32sdk': ['https://github.com/hathach/mm32sdk.git',
'b93e856211060ae825216c6a1d6aa347ec758843',
'mm32'],
'hw/mcu/nordic/nrfx': ['https://github.com/NordicSemiconductor/nrfx.git',
- '7c47cc0a56ce44658e6da2458e86cd8783ccc4a2',
+ '11f57e578c7feea13f21c79ea0efab2630ac68c7',
'nrf'],
'hw/mcu/nuvoton': ['https://github.com/majbthrd/nuc_driver.git',
'2204191ec76283371419fbcec207da02e1bc22fa',
- 'nuc'],
+ 'nuc100_120 nuc121_125 nuc126 nuc505'],
'hw/mcu/nxp/lpcopen': ['https://github.com/hathach/nxp_lpcopen.git',
'b41cf930e65c734d8ec6de04f1d57d46787c76ae',
'lpc11 lpc13 lpc15 lpc17 lpc18 lpc40 lpc43'],
+ 'hw/mcu/nxp/mcuxsdk-core': ['https://github.com/nxp-mcuxpresso/mcuxsdk-core',
+ '0c5c6b16deb211110e06bde896cdff59ab213e16',
+ 'imxrt kinetis_k32l lpc51 lpc55 mcx'],
'hw/mcu/nxp/mcux-sdk': ['https://github.com/nxp-mcuxpresso/mcux-sdk',
'a1bdae309a14ec95a4f64a96d3315a4f89c397c6',
- 'kinetis_k kinetis_k32l2 kinetis_kl lpc51 lpc54 lpc55 mcx imxrt'],
+ 'kinetis_k kinetis_kl lpc54 rw61x'],
+ 'hw/mcu/nxp/mcux-devices-kinetis': ['https://github.com/nxp-mcuxpresso/mcux-devices-kinetis',
+ '98a155e666c54f396e528ec3131f27a5d5b71f76',
+ 'kinetis_k32l'],
+ 'hw/mcu/nxp/mcux-devices-lpc': ['https://github.com/nxp-mcuxpresso/mcux-devices-lpc',
+ '8096b783ec09d0d1c8629025a5f9d8e7df26e520',
+ 'lpc51 lpc55'],
+ 'hw/mcu/nxp/mcux-devices-mcx': ['https://github.com/nxp-mcuxpresso/mcux-devices-mcx',
+ 'ada1c97c761123ec0c179bb9bb9f744bf9a11475',
+ 'mcx'],
+ 'hw/mcu/nxp/mcux-devices-rt': ['https://github.com/nxp-mcuxpresso/mcux-devices-rt',
+ 'dba2b523c9df61f3330bd186242f8210a8e47c45',
+ 'imxrt'],
+ 'hw/mcu/raspberry_pi/FreeRTOS-Kernel': ['https://github.com/raspberrypi/FreeRTOS-Kernel.git',
+ '4f7299d6ea746b27a9dd19e87af568e34bd65b15',
+ 'rp2040'],
'hw/mcu/raspberry_pi/Pico-PIO-USB': ['https://github.com/sekigon-gonnoc/Pico-PIO-USB.git',
'675543bcc9baa8170f868ab7ba316d418dbcf41f',
'rp2040'],
@@ -136,12 +163,15 @@ deps_optional = {
'hw/mcu/st/cmsis-device-wba': ['https://github.com/STMicroelectronics/cmsis-device-wba.git',
'647d8522e5fd15049e9a1cc30ed19d85e5911eaf',
'stm32wba'],
+ 'hw/mcu/st/stm32c5xx-dfp': ['https://github.com/STMicroelectronics/stm32c5xx-dfp.git',
+ '6d0940882511d9430f83af9bd3da6bcb77f79239',
+ 'stm32c5'],
'hw/mcu/st/stm32-mfxstm32l152': ['https://github.com/STMicroelectronics/stm32-mfxstm32l152.git',
'7f4389efee9c6a655b55e5df3fceef5586b35f9b',
'stm32h7'],
'hw/mcu/st/stm32-tcpp0203': ['https://github.com/STMicroelectronics/stm32-tcpp0203.git',
'9918655bff176ac3046ccf378b5c7bbbc6a38d15',
- 'stm32h7rs stm32n6'],
+ 'stm32h5 stm32h7rs stm32n6'],
'hw/mcu/st/stm32c0xx_hal_driver': ['https://github.com/STMicroelectronics/stm32c0xx_hal_driver.git',
'c283b143bef6bdaacf64240ee6f15eb61dad6125',
'stm32c0'],
@@ -205,8 +235,11 @@ deps_optional = {
'hw/mcu/st/stm32wbaxx_hal_driver': ['https://github.com/STMicroelectronics/stm32wbaxx_hal_driver.git',
'9442fbb71f855ff2e64fbf662b7726beba511a24',
'stm32wba'],
+ 'hw/mcu/st/stm32c5xx-drivers': ['https://github.com/STMicroelectronics/stm32c5xx-drivers.git',
+ '79b901285a7efeaf87c4c25db81d24cb5d8c9465',
+ 'stm32c5'],
'hw/mcu/ti': ['https://github.com/hathach/ti_driver.git',
- '143ed6cc20a7615d042b03b21e070197d473e6e5',
+ '083944907e7d08fcb1f614b47598ce45935b8da1',
'msp430 msp432e4 tm4c'],
'hw/mcu/wch/ch32v103': ['https://github.com/openwch/ch32v103.git',
'7578cae0b21f86dd053a1f781b2fc6ab99d0ec17',
@@ -220,6 +253,9 @@ deps_optional = {
'hw/mcu/wch/ch32f20x': ['https://github.com/openwch/ch32f20x.git',
'77c4095087e5ed2c548ec9058e655d0b8757663b',
'ch32f20x'],
+ 'hw/mcu/wch/ch583': ['https://github.com/openwch/ch583.git',
+ 'bd508ad7ceed48377619837051412a651952857f',
+ 'ch583'],
'hw/mcu/artery/at32f403a_407': ['https://github.com/ArteryTek/AT32F403A_407_Firmware_Library.git',
'f2cb360c3d28fada76b374308b8c4c61d37a090b',
'at32f403a_407'],
@@ -241,22 +277,33 @@ deps_optional = {
'hw/mcu/artery/at32f413': ['https://github.com/ArteryTek/AT32F413_Firmware_Library.git',
'f6fe62dfec9fd40c5b63d92fc5ef2c2b5e77a450',
'at32f413'],
+ 'hw/mcu/artery/at32f45x': ['https://github.com/ArteryTek/AT32F45x_Firmware_Library.git',
+ '3d4a1b38be8ebac292e2350ca53bc4bfa4430233',
+ 'at32f45x'],
+ 'hw/mcu/hpmicro/hpm_sdk': ['https://github.com/hpmicro/hpm_sdk',
+ '8d2af741ecc4aaa82d7ee395dc1ce25d7070c3ff',
+ 'hpmicro'],
'lib/CMSIS_5': ['https://github.com/ARM-software/CMSIS_5.git',
'2b7495b8535bdcb306dac29b9ded4cfb679d7e5c',
- 'imxrt kinetis_k32l2 kinetis_kl lpc51 lpc54 lpc55 mcx mm32 msp432e4 nrf saml2x '
+ 'kinetis_k kinetis_kl lpc54 rw61x mm32 msp432e4 nrf samd2x_l2x '
'lpc11 lpc13 lpc15 lpc17 lpc18 lpc40 lpc43 '
'stm32c0 stm32f0 stm32f1 stm32f2 stm32f3 stm32f4 stm32f7 stm32g0 stm32g4 stm32h5 '
- 'stm32h7 stm32h7rs stm32l0 stm32l1 stm32l4 stm32l5 stm32n6 stm32u0 stm32u5 stm32wb stm32wba'
- 'sam3x samd11 samd21 samd51 samd5x_e5x same5x same7x saml2x samg '
+ 'stm32h7 stm32h7rs stm32l0 stm32l1 stm32l4 stm32l5 stm32u0 stm32u5 stm32wb stm32wba '
+ 'sam3x samd11 samd21 samd2x_l2x samd51 samd5x_e5x same5x same7x samg '
'tm4c '],
'lib/CMSIS_6': ['https://github.com/ARM-software/CMSIS_6.git',
- 'b0bbb0423b278ca632cfe1474eb227961d835fd2',
- 'ra'],
+ '6f0a58d01aa9bd2feba212097f9afe7acd991d52',
+ 'imxrt kinetis_k32l ra stm32n6 lpc51 lpc55 mcx stm32c5'],
'lib/sct_neopixel': ['https://github.com/gsteiert/sct_neopixel.git',
'e73e04ca63495672d955f9268e003cffe168fcd8',
'lpc55'],
}
+# Files to remove after cloning to avoid conflicts with TinyUSB's custom versions
+deps_remove_files = {
+ 'lib/fatfs': ['source/ffconf.h'],
+}
+
# combined 2 deps
deps_all = {**deps_mandatory, **deps_optional}
@@ -302,6 +349,13 @@ def get_a_dep(d):
run_cmd(f"{git_cmd} fetch --depth 1 origin {commit}")
run_cmd(f"{git_cmd} checkout FETCH_HEAD")
+ # Remove files that conflict with TinyUSB's custom versions
+ if d in deps_remove_files:
+ for f in deps_remove_files[d]:
+ fp = p / f
+ if fp.exists():
+ fp.unlink()
+
return 0
@@ -321,18 +375,18 @@ def main():
parser.add_argument('-b', '--board', action='append', default=[], help='Boards to fetch')
parser.add_argument('-D', '--define', action='append', default=[], help='Have no effect')
parser.add_argument('-f1', '--build-flags-on', action='append', default=[], help='Have no effect')
- parser.add_argument('--print', action='store_true', help='Print commit hash only')
+ parser.add_argument('--build-name', default=None, help='Have no effect')
+ parser.add_argument('--cflag', action='append', default=[], help='Have no effect')
args = parser.parse_args()
families = args.families
boards = args.board
- print_only = args.print
status = 0
- deps = list(deps_mandatory.keys())
+ deps = []
if 'all' in families:
- deps += deps_optional.keys()
+ deps.extend(deps_optional.keys())
else:
families = list(families)
if boards is not None:
@@ -340,24 +394,16 @@ def main():
f = find_family(b)
if f is not None:
families.append(f)
-
for f in families:
for d in deps_optional:
- if d not in deps and f in deps_optional[d][2]:
+ if d not in deps and f in deps_optional[d][2].split():
deps.append(d)
+ if len(deps) == 0:
+ print('WARN: no additional dependencies found for given boards or families')
- if print_only:
- pvalue = {}
- # print only without arguments, always add CMSIS_5
- if len(families) == 0 and len(boards) == 0:
- deps.append('lib/CMSIS_5')
- for d in deps:
- commit = deps_all[d][1]
- pvalue[d] = commit
- print(pvalue)
- else:
- with Pool() as pool:
- status = sum(pool.map(get_a_dep, deps))
+ deps.extend(deps_mandatory.keys())
+ with Pool() as pool:
+ status = sum(pool.map(get_a_dep, deps))
return status
diff --git a/tools/iar_gen.py b/tools/iar_gen.py
index 8d45659db..571febb2c 100755
--- a/tools/iar_gen.py
+++ b/tools/iar_gen.py
@@ -74,7 +74,7 @@ def ListPath(path, blacklist=[]):
print('</group>')
def List():
- ListPath('src', [ 'template.c', 'dcd_synopsys.c', 'dcd_esp32sx.c' ])
+ ListPath('src', [ 'template.c' ])
ListPath('lib/SEGGER_RTT')
if __name__ == "__main__":
diff --git a/tools/iar_template.ipcf b/tools/iar_template.ipcf
index 2581a4702..035e40b94 100644
--- a/tools/iar_template.ipcf
+++ b/tools/iar_template.ipcf
@@ -69,6 +69,11 @@
<path>$TUSB_DIR$/src/class/net/ncm.h</path>
<path>$TUSB_DIR$/src/class/net/net_device.h</path>
</group>
+ <group name="src/class/printer">
+ <path>$TUSB_DIR$/src/class/printer/printer_device.c</path>
+ <path>$TUSB_DIR$/src/class/printer/printer.h</path>
+ <path>$TUSB_DIR$/src/class/printer/printer_device.h</path>
+ </group>
<group name="src/class/usbtmc">
<path>$TUSB_DIR$/src/class/usbtmc/usbtmc_device.c</path>
<path>$TUSB_DIR$/src/class/usbtmc/usbtmc.h</path>
@@ -217,7 +222,9 @@
</group>
<group name="src/portable/st/stm32_fsdev">
<path>$TUSB_DIR$/src/portable/st/stm32_fsdev/dcd_stm32_fsdev.c</path>
- <path>$TUSB_DIR$/src/portable/st/stm32_fsdev/dcd_stm32_fsdev.h</path>
+ <path>$TUSB_DIR$/src/portable/st/stm32_fsdev/hcd_stm32_fsdev.c</path>
+ <path>$TUSB_DIR$/src/portable/st/stm32_fsdev/fsdev_common.c</path>
+ <path>$TUSB_DIR$/src/portable/st/stm32_fsdev/fsdev_common.h</path>
</group>
<group name="src/portable/st/typec">
<path>$TUSB_DIR$/src/portable/st/typec/typec_stm32.c</path>
diff --git a/tools/make_release.py b/tools/make_release.py
index c1caf3300..71c1e6f64 100755
--- a/tools/make_release.py
+++ b/tools/make_release.py
@@ -1,8 +1,9 @@
#!/usr/bin/env python3
import re
import gen_doc
+import gen_presets
-version = '0.18.0'
+version = '0.20.0'
print('version {}'.format(version))
ver_id = version.split('.')
@@ -45,9 +46,24 @@ with open(f_library_json, 'w') as f:
f.write(fdata)
###################
-# docs/info/changelog.rst
+# sonar-project.properties
###################
+f_sonar_properties = 'sonar-project.properties'
+with open(f_sonar_properties) as f:
+ fdata = f.read()
+fdata = re.sub(r'(sonar\.projectVersion=)\d+\.\d+\.\d+', r'\g<1>{}'.format(version), fdata)
+
+with open(f_sonar_properties, 'w') as f:
+ f.write(fdata)
+# gen docs
gen_doc.gen_deps_doc()
+gen_doc.gen_boards_doc()
+# gen presets
+gen_presets.main()
+
+##################(ver#
+# docs/info/changelog.rst
+###################
print("Update docs/info/changelog.rst")
diff --git a/tools/metrics.py b/tools/metrics.py
new file mode 100644
index 000000000..0e29fc1ab
--- /dev/null
+++ b/tools/metrics.py
@@ -0,0 +1,662 @@
+#!/usr/bin/env python3
+"""Calculate average sizes from bloaty CSV or TinyUSB metrics JSON outputs."""
+
+import argparse
+import csv
+import glob
+import io
+import json
+import os
+import sys
+from collections import defaultdict
+
+
+def expand_files(file_patterns):
+ """Expand file patterns (globs) to list of files.
+
+ Args:
+ file_patterns: List of file paths or glob patterns
+
+ Returns:
+ List of expanded file paths
+ """
+ expanded = []
+ for pattern in file_patterns:
+ if '*' in pattern or '?' in pattern:
+ expanded.extend(glob.glob(pattern))
+ else:
+ expanded.append(pattern)
+ return expanded
+
+
+def parse_bloaty_csv(csv_text, filters=None):
+ """Parse bloaty CSV text and return normalized JSON data structure."""
+
+ filters = filters or []
+ reader = csv.DictReader(io.StringIO(csv_text))
+ size_by_unit = defaultdict(int)
+ symbols_by_unit: dict[str, defaultdict[str, int]] = defaultdict(lambda: defaultdict(int))
+ sections_by_unit: dict[str, defaultdict[str, int]] = defaultdict(lambda: defaultdict(int))
+
+ for row in reader:
+ compile_unit = row.get("compileunits") or row.get("compileunit") or row.get("path")
+ if compile_unit is None:
+ continue
+
+ if str(compile_unit).upper() == "TOTAL":
+ continue
+
+ if filters and not any(filt in compile_unit for filt in filters):
+ continue
+
+ try:
+ vmsize = int(row.get("vmsize", 0))
+ except ValueError:
+ continue
+
+ size_by_unit[compile_unit] += vmsize
+ symbol_name = row.get("symbols", "")
+ if symbol_name:
+ symbols_by_unit[compile_unit][symbol_name] += vmsize
+ section_name = row.get("sections") or row.get("section")
+ if section_name and vmsize:
+ sections_by_unit[compile_unit][section_name] += vmsize
+
+ files = []
+ for unit_path, total_size in size_by_unit.items():
+ symbols = [
+ {"name": sym, "size": sz}
+ for sym, sz in sorted(symbols_by_unit[unit_path].items(), key=lambda x: x[1], reverse=True)
+ ]
+ sections = {sec: sz for sec, sz in sections_by_unit[unit_path].items() if sz}
+ files.append(
+ {
+ "file": os.path.basename(unit_path) or unit_path,
+ "path": unit_path,
+ "size": total_size,
+ "symbols": symbols,
+ "sections": sections,
+ }
+ )
+
+ total_all = sum(size_by_unit.values())
+ return {"files": files, "TOTAL": total_all}
+
+
+def combine_files(input_files, filters=None):
+ """Combine multiple metrics inputs (bloaty CSV or metrics JSON) into a single data set."""
+
+ filters = filters or []
+ all_json_data = {"file_list": [], "data": []}
+
+ for fin in input_files:
+ if not os.path.exists(fin):
+ print(f"Warning: {fin} not found, skipping", file=sys.stderr)
+ continue
+
+ try:
+ if fin.endswith(".json"):
+ with open(fin, "r", encoding="utf-8") as f:
+ json_data = json.load(f)
+ if filters:
+ json_data["files"] = [
+ f
+ for f in json_data.get("files", [])
+ if f.get("path") and any(filt in f["path"] for filt in filters)
+ ]
+ elif fin.endswith(".csv"):
+ with open(fin, "r", encoding="utf-8") as f:
+ csv_text = f.read()
+ json_data = parse_bloaty_csv(csv_text, filters)
+ else:
+ if fin.endswith(".elf"):
+ print(f"Warning: {fin} is an ELF; please run bloaty with --csv output first. Skipping.",
+ file=sys.stderr)
+ else:
+ print(f"Warning: {fin} is not a supported CSV or JSON metrics input. Skipping.",
+ file=sys.stderr)
+ continue
+
+ # Drop any fake TOTAL entries that slipped in as files
+ json_data["files"] = [
+ f for f in json_data.get("files", [])
+ if str(f.get("file", "")).upper() != "TOTAL"
+ ]
+
+ all_json_data["file_list"].append(fin)
+ all_json_data["data"].append(json_data)
+ except Exception as e: # pragma: no cover - defensive
+ print(f"Warning: Failed to analyze {fin}: {e}", file=sys.stderr)
+ continue
+
+ return all_json_data
+
+
+def compute_avg(all_json_data):
+ """Compute average sizes from combined json_data.
+
+ Args:
+ all_json_data: Dictionary with file_list and data from combine_files()
+
+ Returns:
+ json_average: Dictionary with averaged size data
+ """
+ if not all_json_data["data"]:
+ return None
+
+ # Merge files with the same 'file' value and compute averages
+ file_accumulator = {} # key: file name, value: {"sizes": [sizes], "symbols": {name: [sizes]}, "sections": {name: [sizes]}}
+
+ for json_data in all_json_data["data"]:
+ for f in json_data.get("files", []):
+ fname = f["file"]
+ if fname not in file_accumulator:
+ file_accumulator[fname] = {
+ "sizes": [],
+ "path": f.get("path"),
+ "symbols": defaultdict(list),
+ "sections": defaultdict(list),
+ }
+ size_val = f.get("size", 0)
+ file_accumulator[fname]["sizes"].append(size_val)
+ for sym in f.get("symbols", []):
+ name = sym.get("name")
+ if name is None:
+ continue
+ file_accumulator[fname]["symbols"][name].append(sym.get("size", 0))
+ sections_map = f.get("sections") or {}
+ for sname, ssize in sections_map.items():
+ # linkermap -v produces nested dicts {subsection: size}, flatten to total
+ if isinstance(ssize, dict):
+ ssize = sum(ssize.values())
+ file_accumulator[fname]["sections"][sname].append(ssize)
+
+ # Build json_average with averaged values
+ files_average = []
+ for fname, data in file_accumulator.items():
+ avg_size = round(sum(data["sizes"]) / len(data["sizes"])) if data["sizes"] else 0
+ symbols_avg = []
+ for sym_name, sizes in data["symbols"].items():
+ if not sizes:
+ continue
+ symbols_avg.append({"name": sym_name, "size": round(sum(sizes) / len(sizes))})
+ symbols_avg.sort(key=lambda x: x["size"], reverse=True)
+ sections_avg = {
+ sec_name: round(sum(sizes) / len(sizes))
+ for sec_name, sizes in data["sections"].items()
+ if sizes
+ }
+ files_average.append(
+ {
+ "file": fname,
+ "path": data["path"],
+ "size": avg_size,
+ "symbols": symbols_avg,
+ "sections": sections_avg,
+ }
+ )
+
+ total_size = sum(f["size"] for f in files_average) or 1
+
+ for f in files_average:
+ f["percent"] = (f["size"] / total_size) * 100 if total_size else 0
+ for sym in f["symbols"]:
+ sym["percent"] = (sym["size"] / f["size"]) * 100 if f["size"] else 0
+
+ json_average = {
+ "file_list": all_json_data["file_list"],
+ "files": files_average,
+ }
+
+ return json_average
+
+
+def compare_files(base_file, new_file, filters=None):
+ """Compare two CSV or JSON inputs and generate a difference report."""
+ filters = filters or []
+
+ base_avg = compute_avg(combine_files([base_file], filters))
+ new_avg = compute_avg(combine_files([new_file], filters))
+
+ if not base_avg or not new_avg:
+ return None
+
+ base_files = {f["file"]: f for f in base_avg["files"]}
+ new_files = {f["file"]: f for f in new_avg["files"]}
+ all_file_names = set(base_files.keys()) | set(new_files.keys())
+
+ comparison_files = []
+ for fname in sorted(all_file_names):
+ b = base_files.get(fname, {})
+ n = new_files.get(fname, {})
+ b_size = b.get("size", 0)
+ n_size = n.get("size", 0)
+ base_sections = b.get("sections") or {}
+ new_sections = n.get("sections") or {}
+
+ # Symbol diffs
+ b_syms = {s["name"]: s for s in b.get("symbols", [])}
+ n_syms = {s["name"]: s for s in n.get("symbols", [])}
+ all_syms = set(b_syms.keys()) | set(n_syms.keys())
+ symbols = []
+ for sym in all_syms:
+ sb = b_syms.get(sym, {}).get("size", 0)
+ sn = n_syms.get(sym, {}).get("size", 0)
+ symbols.append({"name": sym, "base": sb, "new": sn, "diff": sn - sb})
+ symbols.sort(key=lambda x: abs(x["diff"]), reverse=True)
+
+ comparison_files.append({
+ "file": fname,
+ "size": {"base": b_size, "new": n_size, "diff": n_size - b_size},
+ "symbols": symbols,
+ "sections": {
+ name: {
+ "base": base_sections.get(name, 0),
+ "new": new_sections.get(name, 0),
+ "diff": new_sections.get(name, 0) - base_sections.get(name, 0),
+ }
+ for name in sorted(set(base_sections) | set(new_sections))
+ },
+ })
+
+ base_total = sum(f["size"] for f in base_avg["files"])
+ new_total = sum(f["size"] for f in new_avg["files"])
+ total = {
+ "base": base_total,
+ "new": new_total,
+ "diff": new_total - base_total,
+ }
+
+ return {
+ "base_file": base_file,
+ "new_file": new_file,
+ "total": total,
+ "files": comparison_files,
+ }
+
+
+def get_sort_key(sort_order):
+ """Get sort key function based on sort order.
+
+ Args:
+ sort_order: One of 'size-', 'size+', 'name-', 'name+'
+
+ Returns:
+ Tuple of (key_func, reverse)
+ """
+
+ def _size_val(entry):
+ return entry.get('size', 0)
+
+ if sort_order == 'size-':
+ return _size_val, True
+ elif sort_order == 'size+':
+ return _size_val, False
+ elif sort_order == 'name-':
+ return lambda x: x.get('file', ''), True
+ else: # name+
+ return lambda x: x.get('file', ''), False
+
+
+def format_diff(base, new, diff):
+ """Format a diff value with percentage."""
+ if diff == 0:
+ return f"{new}"
+ if base == 0 or new == 0:
+ return f"{base} ➙ {new}"
+ pct = (diff / base) * 100
+ sign = "+" if diff > 0 else ""
+ return f"{base} ➙ {new} ({sign}{diff}, {sign}{pct:.1f}%)"
+
+
+def write_json_output(json_data, path):
+ """Write JSON output with indentation."""
+
+ with open(path, "w", encoding="utf-8") as outf:
+ json.dump(json_data, outf, indent=2)
+
+
+def render_combine_table(json_data, sort_order='name+'):
+ """Render averaged sizes as markdown table lines (no title)."""
+ files = json_data.get("files", [])
+ if not files:
+ return ["No entries."]
+
+ key_func, reverse = get_sort_key(sort_order)
+ files_sorted = sorted(files, key=key_func, reverse=reverse)
+
+ total_size = json_data.get("TOTAL") or sum(f.get("size", 0) for f in files_sorted)
+
+ pct_strings = [
+ f"{(f.get('percent') if f.get('percent') is not None else (f.get('size', 0) / total_size * 100 if total_size else 0)):.1f}%"
+ for f in files_sorted]
+ pct_width = 6
+ size_width = max(len("size"), *(len(str(f.get("size", 0))) for f in files_sorted), len(str(total_size)))
+ file_width = max(len("File"), *(len(f.get("file", "")) for f in files_sorted), len("TOTAL"))
+
+ # Build section totals on the fly from file data
+ sections_global = defaultdict(int)
+ for f in files_sorted:
+ for name, size in (f.get("sections") or {}).items():
+ sections_global[name] += size
+ # Display sections in reverse alphabetical order for stable column layout
+ section_names = sorted(sections_global.keys(), reverse=True)
+ section_widths = {}
+ for name in section_names:
+ max_val = max((f.get("sections", {}).get(name, 0) for f in files_sorted), default=0)
+ section_widths[name] = max(len(name), len(str(max_val)), 1)
+
+ if not section_names:
+ header = f"| {'File':<{file_width}} | {'size':>{size_width}} | {'%':>{pct_width}} |"
+ separator = f"| :{'-' * (file_width - 1)} | {'-' * (size_width - 1)}: | {'-' * (pct_width - 1)}: |"
+ else:
+ header_parts = [f"| {'File':<{file_width}} |"]
+ sep_parts = [f"| :{'-' * (file_width - 1)} |"]
+ for name in section_names:
+ header_parts.append(f" {name:>{section_widths[name]}} |")
+ sep_parts.append(f" {'-' * (section_widths[name] - 1)}: |")
+ header_parts.append(f" {'size':>{size_width}} | {'%':>{pct_width}} |")
+ sep_parts.append(f" {'-' * (size_width - 1)}: | {'-' * (pct_width - 1)}: |")
+ header = "".join(header_parts)
+ separator = "".join(sep_parts)
+
+ lines = [header, separator]
+
+ for f, pct_str in zip(files_sorted, pct_strings):
+ size_val = f.get("size", 0)
+ parts = [f"| {f.get('file', ''):<{file_width}} |"]
+ if section_names:
+ sections_map = f.get("sections") or {}
+ for name in section_names:
+ parts.append(f" {sections_map.get(name, 0):>{section_widths[name]}} |")
+ parts.append(f" {size_val:>{size_width}} | {pct_str:>{pct_width}} |")
+ lines.append("".join(parts))
+
+ total_parts = [f"| {'TOTAL':<{file_width}} |"]
+ if section_names:
+ for name in section_names:
+ total_parts.append(f" {sections_global.get(name, 0):>{section_widths[name]}} |")
+ total_parts.append(f" {total_size:>{size_width}} | {'100.0%':>{pct_width}} |")
+ lines.append("".join(total_parts))
+ return lines
+
+
+def write_combine_markdown(json_data, path, sort_order='name+', title="TinyUSB Average Code Size Metrics"):
+ """Write averaged size data to a markdown file."""
+
+ md_lines = [f"## {title}", "", "<details><summary>Size table</summary>", ""]
+ md_lines.extend(render_combine_table(json_data, sort_order))
+ md_lines.extend(["", "</details>", ""])
+
+ if json_data.get("file_list"):
+ md_lines.extend(["<details>", "<summary>Input files</summary>", ""])
+ md_lines.extend([f"- {mf}" for mf in json_data["file_list"]])
+ md_lines.extend(["", "</details>", ""])
+
+ with open(path, "w", encoding="utf-8") as f:
+ f.write("\n".join(md_lines))
+
+
+def write_compare_markdown(comparison, path, sort_order='size'):
+ """Write comparison data to markdown file."""
+ md_lines = [
+ "## Size Difference Report",
+ "",
+ "Because TinyUSB code size varies by port and configuration, the metrics below represent the averaged totals across all example builds.",
+ "",
+ "Note: If there is no change, only one value is shown.",
+ "",
+ ]
+
+ significant, minor, unchanged = _split_by_significance(comparison["files"], sort_order)
+
+ def render(title, rows, collapsed=False):
+ if collapsed:
+ md_lines.append(f"<details><summary>{title}</summary>")
+ md_lines.append("")
+ else:
+ md_lines.append(f"### {title}")
+
+ md_lines.extend(render_compare_table(_build_rows(rows, sort_order), include_sum=True))
+ md_lines.append("")
+
+ if collapsed:
+ md_lines.append("</details>")
+ md_lines.append("")
+
+ render("Changes >1% in size", significant)
+ render("Changes <1% in size", minor, collapsed=True)
+ render("No changes", unchanged, collapsed=True)
+
+ with open(path, "w", encoding="utf-8") as f:
+ f.write("\n".join(md_lines))
+
+
+def print_compare_summary(comparison, sort_order='name+'):
+ """Print diff report to stdout in table form."""
+
+ files = comparison["files"]
+
+ rows = _build_rows(files, sort_order)
+ lines = render_compare_table(rows, include_sum=True)
+ for line in lines:
+ print(line)
+
+
+def _build_rows(files, sort_order):
+ """Sort files and prepare printable fields."""
+
+ def sort_key(file_row):
+ if sort_order == 'size-':
+ return abs(file_row["size"]["diff"])
+ if sort_order in ('size', 'size+'):
+ return abs(file_row["size"]["diff"])
+ if sort_order == 'name-':
+ return file_row['file']
+ return file_row['file']
+
+ reverse = sort_order in ('size-', 'name-')
+ files_sorted = sorted(files, key=sort_key, reverse=reverse)
+
+ rows = []
+ for f in files_sorted:
+ sd = f["size"]
+ diff_val = sd['new'] - sd['base']
+ if sd['base'] == 0:
+ pct_str = "n/a"
+ else:
+ pct_val = (diff_val / sd['base']) * 100
+ pct_str = f"{pct_val:+.1f}%"
+ rows.append({
+ "file": f['file'],
+ "base": sd['base'],
+ "new": sd['new'],
+ "diff": diff_val,
+ "pct": pct_str,
+ "sections": f.get("sections", {}),
+ })
+ return rows
+
+
+def _split_by_significance(files, sort_order):
+ """Split files into >1% changes, <1% changes, and no changes."""
+
+ def is_significant(file_row):
+ base = file_row["size"]["base"]
+ diff = abs(file_row["size"]["diff"])
+ if base == 0:
+ return diff != 0
+ return (diff / base) * 100 > 1.0
+
+ rows_sorted = sorted(
+ files,
+ key=lambda f: abs(f["size"]["diff"]) if sort_order.startswith("size") else f["file"],
+ reverse=sort_order in ('size-', 'name-'),
+ )
+
+ significant = []
+ minor = []
+ unchanged = []
+ for f in rows_sorted:
+ if f["size"]["diff"] == 0:
+ unchanged.append(f)
+ else:
+ (significant if is_significant(f) else minor).append(f)
+
+ return significant, minor, unchanged
+
+
+def render_compare_table(rows, include_sum):
+ """Return markdown table lines for given rows."""
+ if not rows:
+ return ["No entries.", ""]
+
+ # collect section columns (reverse alpha)
+ section_names = sorted(
+ {name for r in rows for name in (r.get("sections") or {})},
+ reverse=True,
+ )
+
+ def fmt_abs(val_old, val_new):
+ diff = val_new - val_old
+ if diff == 0:
+ return f"{val_new}"
+ sign = "+" if diff > 0 else ""
+ return f"{val_old} ➙ {val_new} ({sign}{diff})"
+
+ sum_base = sum(r["base"] for r in rows)
+ sum_new = sum(r["new"] for r in rows)
+ total_diff = sum_new - sum_base
+ total_pct = "n/a" if sum_base == 0 else f"{(total_diff / sum_base) * 100:+.1f}%"
+
+ file_width = max(len("file"), *(len(r["file"]) for r in rows), len("TOTAL"))
+ size_width = max(
+ len("size"),
+ *(len(fmt_abs(r["base"], r["new"])) for r in rows),
+ len(fmt_abs(sum_base, sum_new)),
+ )
+ pct_width = max(len("% diff"), *(len(r["pct"]) for r in rows), len(total_pct))
+ section_widths = {}
+ for name in section_names:
+ max_val_len = 0
+ for r in rows:
+ sec_entry = (r.get("sections") or {}).get(name, {"base": 0, "new": 0})
+ max_val_len = max(max_val_len, len(fmt_abs(sec_entry.get("base", 0), sec_entry.get("new", 0))))
+ section_widths[name] = max(len(name), max_val_len, 1)
+
+ header_parts = [f"| {'file':<{file_width}} |"]
+ sep_parts = [f"| :{'-' * (file_width - 1)} |"]
+ for name in section_names:
+ header_parts.append(f" {name:>{section_widths[name]}} |")
+ sep_parts.append(f" {'-' * (section_widths[name] - 1)}: |")
+ header_parts.append(f" {'size':>{size_width}} | {'% diff':>{pct_width}} |")
+ sep_parts.append(f" {'-' * (size_width - 1)}: | {'-' * (pct_width - 1)}: |")
+ header = "".join(header_parts)
+ separator = "".join(sep_parts)
+
+ lines = [header, separator]
+
+ for r in rows:
+ parts = [f"| {r['file']:<{file_width}} |"]
+ sections_map = r.get("sections") or {}
+ for name in section_names:
+ sec_entry = sections_map.get(name, {"base": 0, "new": 0})
+ parts.append(f" {fmt_abs(sec_entry.get('base', 0), sec_entry.get('new', 0)):>{section_widths[name]}} |")
+ parts.append(f" {fmt_abs(r['base'], r['new']):>{size_width}} | {r['pct']:>{pct_width}} |")
+ lines.append("".join(parts))
+
+ if include_sum:
+ total_parts = [f"| {'TOTAL':<{file_width}} |"]
+ for name in section_names:
+ total_base = sum((r.get("sections") or {}).get(name, {}).get("base", 0) for r in rows)
+ total_new = sum((r.get("sections") or {}).get(name, {}).get("new", 0) for r in rows)
+ total_parts.append(f" {fmt_abs(total_base, total_new):>{section_widths[name]}} |")
+ total_parts.append(f" {fmt_abs(sum_base, sum_new):>{size_width}} | {total_pct:>{pct_width}} |")
+ lines.append("".join(total_parts))
+ return lines
+
+
+def cmd_combine(args):
+ """Handle combine subcommand."""
+ input_files = expand_files(args.files)
+ all_json_data = combine_files(input_files, args.filters)
+ json_average = compute_avg(all_json_data)
+
+ if json_average is None:
+ print("No valid map files found", file=sys.stderr)
+ sys.exit(1)
+
+ if not args.quiet:
+ for line in render_combine_table(json_average, sort_order=args.sort):
+ print(line)
+ if args.json_out:
+ write_json_output(json_average, args.out + '.json')
+ if args.markdown_out:
+ write_combine_markdown(json_average, args.out + '.md', sort_order=args.sort,
+ title="TinyUSB Average Code Size Metrics")
+
+
+def cmd_compare(args):
+ """Handle compare subcommand."""
+ comparison = compare_files(args.base, args.new, args.filters)
+
+ if comparison is None:
+ print("Failed to compare files", file=sys.stderr)
+ sys.exit(1)
+
+ if not args.quiet:
+ print_compare_summary(comparison, args.sort)
+ if args.markdown_out:
+ write_compare_markdown(comparison, args.out + '.md', args.sort)
+ if not args.quiet:
+ print(f"Comparison written to {args.out}.md")
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description='Code size metrics tool')
+ subparsers = parser.add_subparsers(dest='command', required=True, help='Available commands')
+
+ # Combine subcommand
+ combine_parser = subparsers.add_parser('combine', help='Combine and average bloaty CSV outputs or metrics JSON files')
+ combine_parser.add_argument('files', nargs='+',
+ help='Path to bloaty CSV output or TinyUSB metrics JSON file(s) (including linkermap-generated) or glob pattern(s)')
+ combine_parser.add_argument('-f', '--filter', dest='filters', action='append', default=[],
+ help='Only include compile units whose path contains this substring (can be repeated)')
+ combine_parser.add_argument('-o', '--out', dest='out', default='metrics',
+ help='Output path basename for JSON and Markdown files (default: metrics)')
+ combine_parser.add_argument('-j', '--json', dest='json_out', action='store_true',
+ help='Write JSON output file')
+ combine_parser.add_argument('-m', '--markdown', dest='markdown_out', action='store_true',
+ help='Write Markdown output file')
+ combine_parser.add_argument('-q', '--quiet', dest='quiet', action='store_true',
+ help='Suppress summary output')
+ combine_parser.add_argument('-S', '--sort', dest='sort', default='size-',
+ choices=['size', 'size-', 'size+', 'name', 'name-', 'name+'],
+ help='Sort order: size/size- (descending), size+ (ascending), name/name+ (ascending), name- (descending). Default: size-')
+
+ # Compare subcommand
+ compare_parser = subparsers.add_parser('compare', help='Compare two metrics inputs (bloaty CSV or metrics JSON)')
+ compare_parser.add_argument('base', help='Base CSV/metrics JSON file')
+ compare_parser.add_argument('new', help='New CSV/metrics JSON file')
+ compare_parser.add_argument('-f', '--filter', dest='filters', action='append', default=[],
+ help='Only include compile units whose path contains this substring (can be repeated)')
+ compare_parser.add_argument('-o', '--out', dest='out', default='metrics_compare',
+ help='Output path basename for Markdown/JSON files (default: metrics_compare)')
+ compare_parser.add_argument('-m', '--markdown', dest='markdown_out', action='store_true',
+ help='Write Markdown output file')
+ compare_parser.add_argument('-S', '--sort', dest='sort', default='name+',
+ choices=['size', 'size-', 'size+', 'name', 'name-', 'name+'],
+ help='Sort order: size/size- (descending), size+ (ascending), name/name+ (ascending), name- (descending). Default: name+')
+ compare_parser.add_argument('-q', '--quiet', dest='quiet', action='store_true',
+ help='Suppress stdout summary output')
+
+ args = parser.parse_args(argv)
+
+ if args.command == 'combine':
+ cmd_combine(args)
+ elif args.command == 'compare':
+ cmd_compare(args)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/metrics_compare_base.py b/tools/metrics_compare_base.py
new file mode 100644
index 000000000..799a96800
--- /dev/null
+++ b/tools/metrics_compare_base.py
@@ -0,0 +1,350 @@
+#!/usr/bin/env python3
+"""Build base branch (master) and current tree, then compare code size metrics.
+
+Creates cmake-metrics/<board>/{base,build} directories for each board.
+With --combined, also writes cmake-metrics/_combined/metrics_compare.md aggregating
+all boards into a single comparison.
+
+Usage:
+ python tools/metrics_compare_base.py -b raspberry_pi_pico
+ python tools/metrics_compare_base.py -b raspberry_pi_pico -b raspberry_pi_pico2
+ python tools/metrics_compare_base.py -b raspberry_pi_pico -f portable/raspberrypi
+ python tools/metrics_compare_base.py -b raspberry_pi_pico -e device/cdc_msc
+ python tools/metrics_compare_base.py -b raspberry_pi_pico -e device/cdc_msc --bloaty
+ python tools/metrics_compare_base.py --ci # first board of each arm-gcc family, combined
+ python tools/metrics_compare_base.py -b pico -b pico2 --combined # aggregate listed boards
+"""
+import argparse
+import glob
+import json
+import os
+import re
+import shlex
+import subprocess
+import sys
+
+TINYUSB_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+METRICS_DIR = os.path.join(TINYUSB_ROOT, 'cmake-metrics')
+
+def tinyusb_src_filter(checkout_dir):
+ """Return a path-substring filter that uniquely matches TinyUSB stack source files
+ in `checkout_dir`. The substring is the absolute path to the checkout's `src/`
+ dir — collision-free with vendored deps (pico-sdk, lwip, FreeRTOS, etc.) which
+ live at unrelated paths."""
+ return os.path.realpath(os.path.join(checkout_dir, 'src')) + os.sep
+
+verbose = False
+
+
+def run(cmd, **kwargs):
+ """Run a command. cmd must be a list (no shell=True). On `timeout=`-induced
+ TimeoutExpired, return a CompletedProcess with rc=124 instead of letting the
+ exception propagate, so the caller can fall through to error reporting and
+ worktree cleanup rather than crashing with a traceback."""
+ if not isinstance(cmd, list):
+ raise TypeError('run() requires a list, got str — fix the caller')
+ if verbose:
+ print(f' $ {" ".join(shlex.quote(str(c)) for c in cmd)}')
+ try:
+ return subprocess.run(cmd, capture_output=True, text=True, **kwargs)
+ except subprocess.TimeoutExpired as e:
+ msg = f'Command timed out after {e.timeout}s: {" ".join(shlex.quote(str(c)) for c in cmd)}'
+ stderr = (e.stderr or '') + ('\n' if e.stderr else '') + msg
+ return subprocess.CompletedProcess(cmd, 124, stdout=(e.stdout or ''), stderr=stderr)
+
+
+def symlink_deps(main_root, worktree_dir):
+ """Symlink dependency directories (fetched by tools/get_deps.py) from the main
+ checkout into the temporary worktree. Without this, the base build fails because
+ the worktree doesn't have the untracked deps."""
+ def link_subdirs(rel_parent):
+ src_parent = os.path.join(main_root, rel_parent)
+ dst_parent = os.path.join(worktree_dir, rel_parent)
+ if not os.path.isdir(src_parent):
+ return
+ os.makedirs(dst_parent, exist_ok=True)
+ for entry in os.listdir(src_parent):
+ src = os.path.join(src_parent, entry)
+ dst = os.path.join(dst_parent, entry)
+ if os.path.isdir(src) and not os.path.exists(dst):
+ os.symlink(src, dst)
+
+ # lib/* and tools/* deps (e.g. lib/lwip, tools/linkermap)
+ link_subdirs('lib')
+ link_subdirs('tools')
+ # hw/mcu/<vendor>/<dep> (e.g. hw/mcu/raspberry_pi/Pico-PIO-USB)
+ hw_mcu = os.path.join(main_root, 'hw', 'mcu')
+ if os.path.isdir(hw_mcu):
+ for vendor in os.listdir(hw_mcu):
+ link_subdirs(os.path.join('hw', 'mcu', vendor))
+
+
+def ci_first_boards():
+ """Return the first board (alphabetical) of each arm-gcc CI family."""
+ matrix_py = os.path.join(TINYUSB_ROOT, '.github', 'workflows', 'ci_set_matrix.py')
+ if not os.path.isfile(matrix_py):
+ return []
+ ret = run([sys.executable, matrix_py])
+ if ret.returncode != 0:
+ return []
+ try:
+ data = json.loads(ret.stdout)
+ except json.JSONDecodeError:
+ return []
+ families = data.get('arm-gcc', [])
+ boards = []
+ bsp_root = os.path.join(TINYUSB_ROOT, 'hw', 'bsp')
+ for family in families:
+ family_boards = sorted(
+ d for d in os.listdir(os.path.join(bsp_root, family, 'boards'))
+ if os.path.isdir(os.path.join(bsp_root, family, 'boards', d))
+ ) if os.path.isdir(os.path.join(bsp_root, family, 'boards')) else []
+ if family_boards:
+ boards.append(family_boards[0])
+ return boards
+
+
+def build_board(src_dir, build_dir, board, example=None):
+ """Configure and build examples for a board. Returns True on success.
+
+ When `example` is given, only that target is built (`cmake --build --target NAME`),
+ keeping single-example workflows fast.
+ """
+ os.makedirs(build_dir, exist_ok=True)
+ ret = run(['cmake', '-B', build_dir, '-G', 'Ninja',
+ f'-DBOARD={board}', '-DCMAKE_BUILD_TYPE=MinSizeRel',
+ os.path.join(src_dir, 'examples')])
+ if ret.returncode != 0:
+ print(f' Error configuring {board}: {ret.stderr}')
+ return False
+ cmd = ['cmake', '--build', build_dir]
+ if example:
+ cmd += ['--target', os.path.basename(example)]
+ ret = run(cmd, timeout=600)
+ if ret.returncode != 0:
+ print(f' Error building {board}: {ret.stderr}')
+ return False
+ return True
+
+
+def generate_metrics(build_dir, out_basename, filters, example=None):
+ """Run metrics.py combine on .map.json files. Returns metrics json path or None.
+
+ `filters` is a list of substrings; metrics.py keeps a compile unit if its path
+ contains any of them.
+ """
+ if example:
+ patterns = glob.glob(f'{build_dir}/{example}/*.map.json')
+ else:
+ patterns = glob.glob(f'{build_dir}/**/*.map.json', recursive=True)
+ if not patterns:
+ print(f' Error: no .map.json files in {build_dir}' + (f' for {example}' if example else ''))
+ return None
+
+ metrics_py = os.path.join(TINYUSB_ROOT, 'tools', 'metrics.py')
+ cmd = [sys.executable, metrics_py, 'combine']
+ for f in filters:
+ cmd += ['-f', f]
+ cmd += ['-j', '-q', '-o', out_basename, *patterns]
+ ret = run(cmd)
+ if ret.returncode != 0:
+ print(f' Error: {ret.stderr}')
+ return None
+ return f'{out_basename}.json'
+
+
+def main():
+ global verbose
+
+ parser = argparse.ArgumentParser(description='Compare code size metrics with base branch')
+ parser.add_argument('-b', '--board', action='append', default=[],
+ help='Board name (repeatable). Required unless --ci is given.')
+ parser.add_argument('-f', '--filter', action='append', default=None,
+ help='Path-substring filter (repeatable). When given, '
+ 'overrides the default and is applied to BOTH base and '
+ 'current builds. Default: each side\'s own absolute '
+ '<checkout>/src/ path, which uniquely matches TinyUSB '
+ 'stack code without colliding with vendored deps.')
+ parser.add_argument('--base-branch', default='master',
+ help='Base branch to compare against (default: master)')
+ parser.add_argument('-e', '--example', action='append', default=None,
+ help='Compare specific example (repeatable, e.g. -e device/cdc_msc -e host/cdc_msc_hid)')
+ parser.add_argument('--bloaty', action='store_true',
+ help='Use bloaty for detailed section/symbol diff (requires -e)')
+ parser.add_argument('--ci', action='store_true',
+ help='Add the first board of every arm-gcc CI family. Implies --combined.')
+ parser.add_argument('--combined', action='store_true',
+ help='Aggregate map.json files across all boards into one comparison '
+ '(in cmake-metrics/_combined/), instead of (or in addition to) per-board.')
+ parser.add_argument('-v', '--verbose', action='store_true',
+ help='Print build commands')
+ args = parser.parse_args()
+ verbose = args.verbose
+
+ if args.bloaty and not args.example:
+ parser.error('--bloaty requires -e/--example')
+
+ if args.ci:
+ args.combined = True
+ ci_boards = ci_first_boards()
+ if not ci_boards:
+ parser.error('--ci: failed to derive boards from .github/workflows/ci_set_matrix.py')
+ # Append, dedup, preserve order
+ seen = set(args.board)
+ for b in ci_boards:
+ if b not in seen:
+ args.board.append(b)
+ seen.add(b)
+
+ if not args.board:
+ parser.error('at least one -b BOARD is required (or pass --ci)')
+
+ metrics_py = os.path.join(TINYUSB_ROOT, 'tools', 'metrics.py')
+ worktree_dir = os.path.join(METRICS_DIR, '_worktree')
+
+ # Per-side filters: when no override is given, each build uses its own
+ # absolute <checkout>/src/ path so we only match TinyUSB stack code from that
+ # checkout (and never vendored-dep `src/` like pico-sdk/src/...).
+ if args.filter:
+ base_filters = cur_filters = list(args.filter)
+ else:
+ base_filters = [tinyusb_src_filter(worktree_dir)]
+ cur_filters = [tinyusb_src_filter(TINYUSB_ROOT)]
+
+ # Step 1: Create worktree for base branch
+ print(f'[1/5] Setting up {args.base_branch} worktree...')
+ if os.path.isdir(worktree_dir):
+ run(['git', '-C', TINYUSB_ROOT, 'worktree', 'remove', '--force', worktree_dir])
+ # --detach: check out the ref at a detached HEAD instead of trying to claim the
+ # branch. Lets us add a worktree of `master` even if master is already checked
+ # out elsewhere (main repo, another worktree).
+ ret = run(['git', '-C', TINYUSB_ROOT, 'worktree', 'add', '--detach',
+ worktree_dir, args.base_branch])
+ if ret.returncode != 0:
+ print(f'Error creating worktree: {ret.stderr}')
+ sys.exit(1)
+
+ # Symlink dependency dirs (lib/*, hw/mcu/*/*, tools/*) so the worktree builds.
+ symlink_deps(TINYUSB_ROOT, worktree_dir)
+
+ try:
+ examples = args.example or [None]
+ # For --combined: track every (base_build, cur_build) pair so we can aggregate at the end.
+ built_pairs = []
+
+ for board in args.board:
+ print(f'\n=== {board} ===')
+ board_dir = os.path.join(METRICS_DIR, board)
+ base_build = os.path.join(board_dir, 'base')
+ cur_build = os.path.join(board_dir, 'build')
+
+ # Build only the requested examples (or all if -e not given). Single-example
+ # mode used to build everything and filter at metric time — that was wasted work.
+ board_failed = False
+ for example in examples:
+ build_label = f' --target {os.path.basename(example)}' if example else ''
+ print(f'[2/5] Building {args.base_branch} for {board}{build_label}...')
+ if not build_board(worktree_dir, base_build, board, example):
+ board_failed = True
+ break
+ print(f'[3/5] Building current for {board}{build_label}...')
+ if not build_board(TINYUSB_ROOT, cur_build, board, example):
+ board_failed = True
+ break
+ if board_failed:
+ continue
+
+ built_pairs.append((board, base_build, cur_build))
+
+ for example in examples:
+ suffix = f'_{example.replace("/", "_")}' if example else ''
+ label = f' ({example})' if example else ''
+
+ # Step 4: Generate metrics
+ print(f'[4/5] Generating metrics for {board}{label}...')
+ base_json = generate_metrics(base_build, os.path.join(board_dir, f'base_metrics{suffix}'),
+ base_filters, example)
+ cur_json = generate_metrics(cur_build, os.path.join(board_dir, f'build_metrics{suffix}'),
+ cur_filters, example)
+ if not base_json or not cur_json:
+ continue
+
+ # Step 5: Compare
+ out_base = os.path.join(board_dir, f'metrics_compare{suffix}')
+ print(f'[5/5] Comparing {board}{label}...')
+ ret = run([sys.executable, metrics_py, 'compare', '-m', '-o', out_base, base_json, cur_json])
+ print(ret.stdout)
+
+ # Optional: bloaty diff
+ if args.bloaty and example:
+ elf_name = os.path.basename(example)
+ base_elf = os.path.join(base_build, example, f'{elf_name}.elf')
+ cur_elf = os.path.join(cur_build, example, f'{elf_name}.elf')
+ if os.path.exists(base_elf) and os.path.exists(cur_elf):
+ # Bloaty expects one regex; OR-join all filters (current side
+ # for the new ELF, base side for the base ELF).
+ bloaty_regex = '(' + '|'.join(
+ re.escape(f) for f in (cur_filters + base_filters)
+ ) + ')'
+ bloaty_common = ['bloaty', '--domain=vm', f'--source-filter={bloaty_regex}']
+ print(f'--- bloaty sections ---')
+ ret = run(bloaty_common + ['-d', 'compileunits,sections', cur_elf, '--', base_elf])
+ print(ret.stdout)
+ print(f'--- bloaty symbols ---')
+ ret = run(bloaty_common + ['-d', 'compileunits,symbols', '-s', 'vm',
+ cur_elf, '--', base_elf])
+ print(ret.stdout)
+ else:
+ print(f' bloaty: ELF not found')
+
+ # Optional combined comparison across all boards.
+ # Aggregates the per-board metrics JSONs (not raw map.json globs) so the argv
+ # stays small even with --ci spanning many boards.
+ if args.combined and built_pairs:
+ combined_dir = os.path.join(METRICS_DIR, '_combined')
+ os.makedirs(combined_dir, exist_ok=True)
+
+ # Use the no-suffix per-board JSONs (whole-board metrics). Combined mode
+ # is meant for board-level sweeps; -e/--example combinations skip combined.
+ base_jsons, cur_jsons = [], []
+ for board, _, _ in built_pairs:
+ bj = os.path.join(METRICS_DIR, board, 'base_metrics.json')
+ cj = os.path.join(METRICS_DIR, board, 'build_metrics.json')
+ if os.path.isfile(bj) and os.path.isfile(cj):
+ base_jsons.append(bj)
+ cur_jsons.append(cj)
+
+ if not base_jsons or not cur_jsons:
+ print(' combined: no per-board metrics found (did you pass -e? skip --combined with -e)')
+ else:
+ print(f'\n=== combined ({len(base_jsons)} boards) ===')
+ base_out = os.path.join(combined_dir, 'base_metrics')
+ cur_out = os.path.join(combined_dir, 'build_metrics')
+
+ # Per-board JSONs are already filtered to TinyUSB-only files; combine
+ # without re-filtering so we don't accidentally drop entries.
+ def _combine(out_basename, inputs):
+ cmd = [sys.executable, metrics_py, 'combine',
+ '-j', '-q', '-o', out_basename, *inputs]
+ return run(cmd)
+
+ ret = _combine(base_out, base_jsons)
+ if ret.returncode != 0:
+ print(f' combined base error: {ret.stderr}')
+ else:
+ ret = _combine(cur_out, cur_jsons)
+ if ret.returncode != 0:
+ print(f' combined current error: {ret.stderr}')
+ else:
+ out_combined = os.path.join(combined_dir, 'metrics_compare')
+ ret = run([sys.executable, metrics_py, 'compare', '-m',
+ '-o', out_combined, f'{base_out}.json', f'{cur_out}.json'])
+ print(ret.stdout)
+ print(f' combined report: {out_combined}.md')
+ finally:
+ print(f'\nCleaning up worktree...')
+ run(['git', '-C', TINYUSB_ROOT, 'worktree', 'remove', '--force', worktree_dir])
+
+
+if __name__ == '__main__':
+ main()