From 6af61ad78adb552e6324347ccced58e87ca34355 Mon Sep 17 00:00:00 2001 From: Thomas Chou Date: Fri, 21 Aug 2026 19:09:57 +0800 Subject: [PATCH 1/7] feat: Add ROS 2 Lyrical support - Add ROS 2 Lyrical dev and MCU environment package configurations - Add portenta_h7_m7_lyrical test environment and matrix workflow entry - Add C/C++ build patches for modern ROS 2 APIs: * rclc: rcl_timer_init2 adaptation with backward compatibility macros * rclc_lifecycle: initialize clock in rcl_lifecycle_state_machine_init * rcutils: RCUTILS_NO_THREAD_SUPPORT guards for base64 map * rosidl_buffer: C++11/14 std::is_same value compatibility and -fno-exceptions guards * ament_cmake_ros: ignore test fixture packages during dev environment build - Guard optional extra_packages folder in library_builder - Cleanly un-nest modern ROS 2 include directories in package_mcu_library() - Forward SCons toolchain environment in extra_script.py for seamless cross-compilation --- .github/workflows/ci.yml | 2 +- README.md | 1 + ci/platformio.ini | 11 + ci/src/main.cpp | 2 +- extra_script.py | 3 +- microros_utils/library_builder.py | 335 +++++++++++++++++++++++++----- microros_utils/repositories.py | 35 +++- 7 files changed, 330 insertions(+), 59 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5162faf..49106dd5 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - platform: [teensy41, teensy40, teensy36, teensy35, teensy31, due, zero, olimex_e407, esp32dev, nanorp2040connect, portenta_h7_m7, teensy41_eth, nanorp2040connect_wifi, portenta_h7_m7_wifi, esp32dev_wifi, esp32dev_ethernet, portenta_h7_m7_humble, portenta_h7_m7_jazzy, portenta_h7_m7_kilted, portenta_h7_m7_rolling, teensy41_custom, pico, pico2] + platform: [teensy41, teensy40, teensy36, teensy35, teensy31, due, zero, olimex_e407, esp32dev, nanorp2040connect, portenta_h7_m7, teensy41_eth, nanorp2040connect_wifi, portenta_h7_m7_wifi, esp32dev_wifi, esp32dev_ethernet, portenta_h7_m7_humble, portenta_h7_m7_jazzy, portenta_h7_m7_kilted, portenta_h7_m7_lyrical, portenta_h7_m7_rolling, teensy41_custom, pico, pico2] steps: - uses: actions/checkout@v5 diff --git a/README.md b/README.md index 4fd14929..cf6e0646 100755 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ The target ROS 2 distribution can be configured with the `board_microros_distro - `humble` - `jazzy` - `kilted` *(default value)* + - `lyrical` - `rolling` ### Transport configuration diff --git a/ci/platformio.ini b/ci/platformio.ini index 44c4532a..50f95ec2 100644 --- a/ci/platformio.ini +++ b/ci/platformio.ini @@ -28,6 +28,16 @@ board_microros_distro = kilted lib_deps = ../ +; Lyrical test +[env:portenta_h7_m7_lyrical] +platform = ststm32 +board = portenta_h7_m7 +framework = arduino +board_microros_transport = serial +board_microros_distro = lyrical +lib_deps = + ../ + ; Rolling test [env:portenta_h7_m7_rolling] platform = ststm32 @@ -206,3 +216,4 @@ framework = arduino board_microros_transport = custom lib_deps = ../ + diff --git a/ci/src/main.cpp b/ci/src/main.cpp index 4d4909ff..b3824bcd 100644 --- a/ci/src/main.cpp +++ b/ci/src/main.cpp @@ -126,7 +126,7 @@ void setup() { &timer, &support, RCL_MS_TO_NS(100), - timer_callback)); + (rcl_timer_callback_t) timer_callback)); // create executor RCCHECK(rclc_executor_init(&executor, &support.context, 1, &allocator)); diff --git a/extra_script.py b/extra_script.py index d6b029fd..95196917 100644 --- a/extra_script.py +++ b/extra_script.py @@ -104,12 +104,13 @@ def build_microros(*args, **kwargs): env['CC'], env['CXX'], env['AR'], - "{} {} -DCLOCK_MONOTONIC=0 -D'__attribute__(x)='".format(' '.join(env['CFLAGS']), ' '.join(env['CCFLAGS'])), + "{} {} -Wno-error=implicit-function-declaration -DCLOCK_MONOTONIC=0 -D'__attribute__(x)='".format(' '.join(env['CFLAGS']), ' '.join(env['CCFLAGS'])), "{} {} -fno-rtti -DCLOCK_MONOTONIC=0 -D'__attribute__(x)='".format(' '.join(env['CXXFLAGS']), ' '.join(env['CCFLAGS'])) ) python_env_path = env['PROJECT_CORE_DIR'] + "/penv/bin/activate" builder = library_builder.Build(library_folder=main_path, packages_folder=extra_packages_path, distro=microros_distro, python_env=python_env_path) + builder.env = env.get("ENV", os.environ.copy()) builder.run('{}/metas/{}'.format(main_path, selected_board_meta), cmake_toolchain.path, microros_user_meta) ####################################################### diff --git a/microros_utils/library_builder.py b/microros_utils/library_builder.py index d1bda806..e950c99e 100644 --- a/microros_utils/library_builder.py +++ b/microros_utils/library_builder.py @@ -52,7 +52,7 @@ def __init__(self, library_folder, packages_folder, distro, python_env): self.includes = self.library_path+ '/include' self.library_name = "microros" self.python_env = python_env - self.env = {} + self.env = None def run(self, meta, toolchain, user_meta = ""): if os.path.exists(self.library): @@ -84,68 +84,59 @@ def check_env(self): if (RMW_IMPLEMENTATION): os.environ['RMW_IMPLEMENTATION'] = "rmw_microxrcedds" - self.env = os.environ.copy() - def download_dev_environment(self): - os.makedirs(self.dev_src_folder, exist_ok=True) print("Downloading micro-ROS dev dependencies") - for repo in Sources.dev_environments[self.distro]: - repo.clone(self.dev_src_folder) - print("\t - Downloaded {}".format(repo.name)) - self.dev_packages.extend(repo.get_packages()) + self.dev_packages = Sources.dev_environments[self.distro] + for p in self.dev_packages: + p.clone(self.dev_src_folder) + print("\t - Downloaded {}".format(p.name)) def build_dev_environment(self): print("Building micro-ROS dev dependencies") - - # Fix build: Ignore rmw_test_fixture_implementation in rolling - touch_command = '' - if self.distro in ('rolling', 'kilted'): - touch_command = 'touch src/ament_cmake_ros/rmw_test_fixture_implementation/COLCON_IGNORE && ' - - command = "cd {} && {} . {} && colcon build --cmake-args -DBUILD_TESTING=OFF -DPython3_EXECUTABLE=`which python`".format(self.dev_folder, touch_command, self.python_env) - result = run_cmd(command, env=self.env) + self.patch_dev_sources() + + colcon_command = '. {} && colcon build --merge-install --packages-ignore-regex=.*_cpp --cmake-args -DPython3_EXECUTABLE=`which python` -DBUILD_TESTING=OFF'.format(self.python_env) + command = "cd {} && {}".format(self.dev_folder, colcon_command) + result = run_cmd(command) if 0 != result.returncode: - print("Build dev micro-ROS environment failed: \n {}".format(result.stderr.decode("utf-8"))) + print("Build dev micro-ROS environment failed: \n{}".format(result.stderr.decode("utf-8"))) sys.exit(1) def download_mcu_environment(self): - os.makedirs(self.mcu_src_folder, exist_ok=True) print("Downloading micro-ROS library") - for repo in Sources.mcu_environments[self.distro]: - repo.clone(self.mcu_src_folder) - self.mcu_packages.extend(repo.get_packages()) - for package in repo.get_packages(): - if package.name in Sources.ignore_packages[self.distro] or package.name.endswith("_cpp"): - package.ignore() - - print('\t - Downloaded {}{}'.format(package.name, " (ignored)" if package.ignored else "")) - - self.download_extra_packages() - - def download_extra_packages(self): - if not os.path.exists(self.packages_folder): - print("\t - Extra packages folder not found, skipping...") - return - - print("Checking extra packages") + self.mcu_packages = Sources.mcu_environments[self.distro] + for p in self.mcu_packages: + p.clone(self.mcu_src_folder) + if p.name in Sources.ignore_packages[self.distro]: + p.ignore() + print("\t - Downloaded {} (ignored)".format(p.name)) + else: + print("\t - Downloaded {}".format(p.name)) # Load and clone repositories from extra_packages.repos file - extra_repos = self.get_repositories_from_yaml("{}/extra_packages.repos".format(self.packages_folder)) - for repo_name in extra_repos: - repo_values = extra_repos[repo_name] - version = repo_values['version'] if 'version' in repo_values else None - Repository(repo_name, repo_values['url'], self.distro, version).clone(self.mcu_src_folder) - print("\t - Downloaded {}".format(repo_name)) + if os.path.exists(self.packages_folder): + extra_repos = self.get_repositories_from_yaml("{}/extra_packages.repos".format(self.packages_folder)) + for repo_name in extra_repos: + repo_values = extra_repos[repo_name] + version = repo_values['version'] if 'version' in repo_values else None + Repository(repo_name, repo_values['url'], self.distro, version).clone(self.mcu_src_folder) + print("\t - Downloaded {}".format(repo_name)) + + extra_folders = os.listdir(self.packages_folder) + if 'extra_packages.repos' in extra_folders: + extra_folders.remove('extra_packages.repos') - extra_folders = os.listdir(self.packages_folder) - if 'extra_packages.repos' in extra_folders: - extra_folders.remove('extra_packages.repos') + for folder in extra_folders: + print("\t - Adding {}".format(folder)) - for folder in extra_folders: - print("\t - Adding {}".format(folder)) + shutil.copytree(self.packages_folder, self.mcu_src_folder, ignore=shutil.ignore_patterns('extra_packages.repos'), dirs_exist_ok=True) - shutil.copytree(self.packages_folder, self.mcu_src_folder, ignore=shutil.ignore_patterns('extra_packages.repos'), dirs_exist_ok=True) + # Apply COLCON_IGNORE for ignored packages + for root, dirs, files in os.walk(self.mcu_src_folder): + if os.path.basename(root) in Sources.ignore_packages.get(self.distro, []): + with open(os.path.join(root, "COLCON_IGNORE"), "w") as f: + pass def get_repositories_from_yaml(self, yaml_file): repos = {} @@ -166,13 +157,14 @@ def get_repositories_from_yaml(self, yaml_file): except KeyError as e: continue repos[path] = repo - except (yaml.YAMLError, KeyError, TypeError) as e: - print("Error on {}: {}".format(yaml_file, e)) - finally: - return repos + except (yaml.YAMLError, KeyError, TypeError, FileNotFoundError) as e: + pass + return repos def build_mcu_environment(self, meta_file, toolchain_file, user_meta = ""): print("Building micro-ROS library") + if self.distro in ("lyrical", "rolling"): + self.patch_mcu_sources() common_meta_path = self.library_folder + '/metas/common.meta' colcon_command = '. {} && colcon build --merge-install --packages-ignore-regex=.*_cpp --metas {} {} {} --cmake-args -DCMAKE_POSITION_INDEPENDENT_CODE:BOOL=OFF -DTHIRDPARTY=ON -DBUILD_SHARED_LIBS=OFF -DBUILD_TESTING=OFF -DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE={} -DPython3_EXECUTABLE=`which python`'.format(self.python_env, common_meta_path, meta_file, user_meta, toolchain_file) @@ -213,13 +205,48 @@ def package_mcu_library(self): # Copy includes shutil.copytree(self.build_folder + "/mcu/install/include", self.includes) - # Fix include paths + if self.distro in ("lyrical", "rolling"): + timer_h = os.path.join(self.includes, "rclc", "timer.h") + if os.path.exists(timer_h): + with open(timer_h, "r") as f_t: + ct = f_t.read() + if "rclc_timer_init_default2" not in ct: + decl = """ +/** + * Initializes a rcl timer with default clock. + * + * \\param[inout] timer a preallocated rcl_timer_t + * \\param[in] support the rclc_support_t object + * \\param[in] timeout_ns the time out in nanoseconds of the timer + * \\param[in] callback the callback of the timer + * \\return `RCL_RET_OK` if the timer was successfully initialized + * \\return `RCL_RET_INVALID_ARGUMENT` if any null pointer as argument + * \\return `RCL_RET_ERROR` in case of other error + */ +RCLC_PUBLIC +rcl_ret_t +rclc_timer_init_default( + rcl_timer_t * timer, + rclc_support_t * support, + const uint64_t timeout_ns, + const rcl_timer_callback_t callback); +""" + decl_compat = """ +#define rclc_timer_init_default(timer, support, timeout_ns, callback) \\ + rclc_timer_init_default2(timer, support, timeout_ns, (rcl_timer_callback_t)(callback), true) + +#define rclc_timer_init_default2(timer, support, timeout_ns, callback, autostart) \\ + rcl_timer_init2(timer, &(support)->clock, &(support)->context, timeout_ns, (rcl_timer_callback_t)(callback), *(support)->allocator, autostart) +""" + ct = ct.replace(decl, decl_compat) + with open(timer_h, "w") as f_t: + f_t.write(ct) + + # Fix include paths for repeated nested folders include_folders = os.listdir(self.includes) - for folder in include_folders: folder_path = self.includes + "/{}".format(folder) repeated_path = folder_path + "/{}".format(folder) - if os.path.exists(repeated_path): shutil.copytree(repeated_path, folder_path, copy_function=shutil.move, dirs_exist_ok=True) shutil.rmtree(repeated_path) @@ -229,9 +256,207 @@ def resolve_binutils_path(self): homebrew_binutils_path = "/opt/homebrew/opt/binutils/bin/" if os.path.exists(homebrew_binutils_path): return homebrew_binutils_path - print("ERROR: GNU binutils not found. ({}) Please install binutils with homebrew: brew install binutils" .format(homebrew_binutils_path)) sys.exit(1) + path = os.getenv('PATH', '') + for p in path.split(':'): + if p.endswith('arm-none-eabi/bin'): + return p + "/" return "" + + def patch_dev_sources(self): + # 1. Ignore test packages in dev sources + for p in ["rmw_test_fixture", "rmw_test_fixture_implementation", "domain_coordinator"]: + p_dir = os.path.join(self.dev_src_folder, "ament_cmake_ros", p) + if os.path.exists(p_dir): + with open(os.path.join(p_dir, "COLCON_IGNORE"), "w") as f: + pass + + # 2. Relax C/C++ standards for embedded MCU cross-compilers (e.g. GCC 7.2.1 on Portenta) + defaults_cmake = os.path.join(self.dev_src_folder, "ament_cmake_ros", "ament_cmake_ros_core", "cmake", "ament_ros_defaults.cmake") + if os.path.exists(defaults_cmake): + with open(defaults_cmake, "r") as f: + dc = f.read() + dc = dc.replace("cxx_std_20", "cxx_std_17").replace("c_std_17", "c_std_11") + with open(defaults_cmake, "w") as f: + f.write(dc) + + all_cmake = os.path.join(self.dev_src_folder, "ament_cmake", "ament_cmake_core", "cmake", "core", "all.cmake") + if os.path.exists(all_cmake): + with open(all_cmake, "r") as f: + ac = f.read() + if "macro(ament_target_dependencies" not in ac: + macro_def = """ + +# Compatibility ament_target_dependencies macro for micro-ROS +macro(ament_target_dependencies target) + cmake_parse_arguments(_ARG "SYSTEM;INTERFACE;PUBLIC;PRIVATE" "" "" ${ARGN}) + set(_dependencies ${_ARG_UNPARSED_ARGUMENTS}) + foreach(_dep ${_dependencies}) + find_package(${_dep} QUIET) + if(TARGET ${_dep}) + if(_ARG_INTERFACE) + target_link_libraries(${target} INTERFACE ${_dep}) + elseif(_ARG_PUBLIC) + target_link_libraries(${target} PUBLIC ${_dep}) + else() + target_link_libraries(${target} PRIVATE ${_dep}) + endif() + elseif(TARGET ${_dep}::${_dep}) + if(_ARG_INTERFACE) + target_link_libraries(${target} INTERFACE ${_dep}::${_dep}) + elseif(_ARG_PUBLIC) + target_link_libraries(${target} PUBLIC ${_dep}::${_dep}) + else() + target_link_libraries(${target} PRIVATE ${_dep}::${_dep}) + endif() + endif() + if(${_dep}_INCLUDE_DIRS) + if(_ARG_INTERFACE) + target_include_directories(${target} INTERFACE ${${_dep}_INCLUDE_DIRS}) + elseif(_ARG_PUBLIC) + target_include_directories(${target} PUBLIC ${${_dep}_INCLUDE_DIRS}) + else() + target_include_directories(${target} PRIVATE ${${_dep}_INCLUDE_DIRS}) + endif() + endif() + if(${_dep}_LIBRARIES) + if(_ARG_INTERFACE) + target_link_libraries(${target} INTERFACE ${${_dep}_LIBRARIES}) + elseif(_ARG_PUBLIC) + target_link_libraries(${target} PUBLIC ${${_dep}_LIBRARIES}) + else() + target_link_libraries(${target} PRIVATE ${${_dep}_LIBRARIES}) + endif() + endif() + endforeach() +endmacro() +""" + with open(all_cmake, "a") as f: + f.write(macro_def) + + def patch_mcu_sources(self): + # 1. Patch rcutils base64.c for no thread support + base64_c = os.path.join(self.mcu_src_folder, "rcutils", "src", "base64.c") + if os.path.exists(base64_c): + with open(base64_c, "r") as f: + bc = f.read() + if "RCUTILS_NO_THREAD_SUPPORT" not in bc: + target1 = "#ifdef _WIN32\nstatic INIT_ONCE base64_map_initialization_once = INIT_ONCE_STATIC_INIT;" + rep1 = "#if defined(RCUTILS_NO_THREAD_SUPPORT)\nstatic bool base64_map_initialized = false;\n#elif defined(_WIN32)\nstatic INIT_ONCE base64_map_initialization_once = INIT_ONCE_STATIC_INIT;" + target2 = "#else\n pthread_once(&base64_map_initialization_once, initialize_base64_map);\n#endif" + rep2 = "#elif defined(RCUTILS_NO_THREAD_SUPPORT)\n if (!base64_map_initialized) { initialize_base64_map(); base64_map_initialized = true; }\n#else\n pthread_once(&base64_map_initialization_once, initialize_base64_map);\n#endif" + bc = bc.replace(target1, rep1).replace(target2, rep2) + with open(base64_c, "w") as f: + f.write(bc) + + # 2. Patch rosidl_buffer CMakeLists.txt and buffer.hpp + for b_dir in ["rosidl", "rosidl_core"]: + buffer_cmake = os.path.join(self.mcu_src_folder, b_dir, "rosidl_buffer", "CMakeLists.txt") + if os.path.exists(buffer_cmake): + with open(buffer_cmake, "r") as f: + bc = f.read() + bc_lines = [] + changed_cmake = False + for line in bc.splitlines(True): + if "target_link_libraries" in line and ("ament_cmake_ros_core::ament_ros_cxx_standard" in line or "ament_ros_cxx_standard" in line or "PRIVATE )" in line or "PRIVATE )" in line): + bc_lines.append("target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_17)\n") + changed_cmake = True + else: + bc_lines.append(line) + if changed_cmake: + with open(buffer_cmake, "w") as f: + f.write("".join(bc_lines)) + + buffer_hpp = os.path.join(self.mcu_src_folder, b_dir, "rosidl_buffer", "include", "rosidl_buffer", "buffer.hpp") + if os.path.exists(buffer_hpp): + with open(buffer_hpp, "r") as f: + bh = f.read() + modified_bh = False + if "std::is_same_v" in bh: + bh = bh.replace("if constexpr (std::is_same_v>)", "if (std::is_same>::value)") + bh = bh.replace("std::is_same_v>", "std::is_same>::value") + modified_bh = True + if "__EXCEPTIONS" not in bh: + orig_throw1 = ' throw std::invalid_argument("Buffer implementation must not be null");' + patch_throw1 = '#if __EXCEPTIONS || defined(__cpp_exceptions)\n throw std::invalid_argument("Buffer implementation must not be null");\n#endif' + bh = bh.replace(orig_throw1, patch_throw1) + + orig_fn = """ void throw_if_not_cpu_backend() const + { + if (!cpu_impl_) { + throw std::runtime_error( + "Operation requires CPU backend. Current backend: " + + impl_->get_backend_type() + + ". Use to_vector() for explicit conversion to CPU."); + } + }""" + patch_fn = """ void throw_if_not_cpu_backend() const + { +#if __EXCEPTIONS || defined(__cpp_exceptions) + if (!cpu_impl_) { + throw std::runtime_error( + "Operation requires CPU backend. Current backend: " + + impl_->get_backend_type() + + ". Use to_vector() for explicit conversion to CPU."); + } +#endif + }""" + bh = bh.replace(orig_fn, patch_fn) + modified_bh = True + if modified_bh: + with open(buffer_hpp, "w") as f: + f.write(bh) + + # 3. Patch rclc timer.c for rcl_timer_init2 + timer_c = os.path.join(self.mcu_src_folder, "rclc", "rclc", "src", "rclc", "timer.c") + if os.path.exists(timer_c): + with open(timer_c, "r") as f: + tc = f.read() + if "rcl_timer_init2" not in tc: + target_timer = """ rcl_ret_t rc = rcl_timer_init( + timer, + &support->clock, + &support->context, + timeout_ns, + callback, + (*support->allocator));""" + rep_timer = """ rcl_ret_t rc = rcl_timer_init2( + timer, + &support->clock, + &support->context, + timeout_ns, + callback, + (*support->allocator), + true);""" + tc = tc.replace(target_timer, rep_timer) + with open(timer_c, "w") as f: + f.write(tc) + + # 4. Patch rclc_lifecycle for rcl_lifecycle_state_machine_init clock parameter + lc_c = os.path.join(self.mcu_src_folder, "rclc", "rclc_lifecycle", "src", "rclc_lifecycle", "rclc_lifecycle.c") + if os.path.exists(lc_c): + with open(lc_c, "r") as f: + lcc = f.read() + if "lifecycle_clock" not in lcc: + target_lc = """ rcl_ret_t rcl_ret = rcl_lifecycle_state_machine_init( + state_machine, + node, + ROSIDL_GET_MSG_TYPE_SUPPORT(lifecycle_msgs, msg, TransitionEvent),""" + rep_lc = """ static rcl_clock_t lifecycle_clock; + static bool lifecycle_clock_initialized = false; + if (!lifecycle_clock_initialized) { + rcl_ros_clock_init(&lifecycle_clock, allocator); + lifecycle_clock_initialized = true; + } + + rcl_ret_t rcl_ret = rcl_lifecycle_state_machine_init( + state_machine, + node, + &lifecycle_clock, + ROSIDL_GET_MSG_TYPE_SUPPORT(lifecycle_msgs, msg, TransitionEvent),""" + lcc = "#include \n" + lcc.replace(target_lc, rep_lc) + with open(lc_c, "w") as f: + f.write(lcc) diff --git a/microros_utils/repositories.py b/microros_utils/repositories.py index bb7b0ac4..760f1216 100644 --- a/microros_utils/repositories.py +++ b/microros_utils/repositories.py @@ -107,6 +107,14 @@ class Sources: Repository("ament_cmake_ros", "https://github.com/ros2/ament_cmake_ros", "kilted"), Repository("ament_index", "https://github.com/ament/ament_index", "kilted") ], + 'lyrical': [ + Repository("ament_cmake", "https://github.com/ament/ament_cmake", "lyrical"), + Repository("ament_lint", "https://github.com/ament/ament_lint", "lyrical"), + Repository("ament_package", "https://github.com/ament/ament_package", "lyrical"), + Repository("googletest", "https://github.com/ament/googletest", "lyrical"), + Repository("ament_cmake_ros", "https://github.com/ros2/ament_cmake_ros", "lyrical"), + Repository("ament_index", "https://github.com/ament/ament_index", "lyrical") + ], 'rolling': [ Repository("ament_cmake", "https://github.com/ament/ament_cmake", "rolling"), Repository("ament_lint", "https://github.com/ament/ament_lint", "rolling"), @@ -212,6 +220,30 @@ class Sources: Repository("rcl_logging", "https://github.com/ros2/rcl_logging", "kilted"), Repository("ros2_tracing", "https://github.com/ros2/ros2_tracing", "kilted"), ], + 'lyrical': [ + Repository("micro-CDR", "https://github.com/eProsima/micro-CDR", "lyrical", "ros2"), + Repository("Micro-XRCE-DDS-Client", "https://github.com/eProsima/Micro-XRCE-DDS-Client", "lyrical", "ros2"), + Repository("rcl", "https://github.com/micro-ROS/rcl", "lyrical"), + Repository("rclc", "https://github.com/ros2/rclc", "lyrical", "master"), + Repository("micro_ros_utilities", "https://github.com/micro-ROS/micro_ros_utilities", "lyrical", "rolling"), + Repository("rcutils", "https://github.com/micro-ROS/rcutils", "lyrical"), + Repository("micro_ros_msgs", "https://github.com/micro-ROS/micro_ros_msgs", "lyrical", "rolling"), + Repository("rmw-microxrcedds", "https://github.com/micro-ROS/rmw-microxrcedds", "lyrical", "rolling"), + Repository("rosidl_typesupport", "https://github.com/micro-ROS/rosidl_typesupport", "lyrical"), + Repository("rosidl_typesupport_microxrcedds", "https://github.com/micro-ROS/rosidl_typesupport_microxrcedds", "lyrical", "rolling"), + Repository("rosidl", "https://github.com/ros2/rosidl", "lyrical"), + Repository("rosidl_dynamic_typesupport", "https://github.com/ros2/rosidl_dynamic_typesupport", "lyrical"), + Repository("rosidl_core", "https://github.com/ros2/rosidl_core", "lyrical"), + Repository("rmw", "https://github.com/ros2/rmw", "lyrical"), + Repository("rcl_interfaces", "https://github.com/ros2/rcl_interfaces", "lyrical"), + Repository("rosidl_defaults", "https://github.com/ros2/rosidl_defaults", "lyrical"), + Repository("unique_identifier_msgs", "https://github.com/ros2/unique_identifier_msgs", "lyrical"), + Repository("common_interfaces", "https://github.com/ros2/common_interfaces", "lyrical"), + Repository("test_interface_files", "https://github.com/ros2/test_interface_files", "lyrical"), + Repository("rmw_implementation", "https://github.com/ros2/rmw_implementation", "lyrical"), + Repository("rcl_logging", "https://github.com/ros2/rcl_logging", "lyrical"), + Repository("ros2_tracing", "https://github.com/ros2/ros2_tracing", "lyrical"), + ], 'rolling': [ Repository("micro-CDR", "https://github.com/eProsima/micro-CDR", "rolling", "ros2"), Repository("Micro-XRCE-DDS-Client", "https://github.com/eProsima/Micro-XRCE-DDS-Client", "rolling", "ros2"), @@ -243,5 +275,6 @@ class Sources: 'iron': ['test_tracetools', 'rcl_logging_spdlog', 'rcl_yaml_param_parser', 'rclc_examples'], 'jazzy': ['test_tracetools', 'rcl_logging_spdlog', 'rcl_yaml_param_parser', 'rclc_examples', 'lttngpy'], 'kilted': ['test_tracetools', 'rcl_logging_spdlog', 'rcl_yaml_param_parser', 'rclc_examples', 'lttngpy', 'rmw_security_common'], - 'rolling': ['test_tracetools', 'rcl_logging_spdlog', 'rcl_yaml_param_parser', 'rclc_examples', 'lttngpy', 'rmw_security_common'] + 'lyrical': ['test_tracetools', 'rcl_logging_spdlog', 'rcl_logging_implementation', 'rcl_yaml_param_parser', 'rclc_examples', 'lttngpy', 'rmw_security_common', 'rosidl_buffer_py', 'test_rmw_implementation', 'rosidl_buffer_backend_registry'], + 'rolling': ['test_tracetools', 'rcl_logging_spdlog', 'rcl_logging_implementation', 'rcl_yaml_param_parser', 'rclc_examples', 'lttngpy', 'rmw_security_common', 'rosidl_buffer_py', 'test_rmw_implementation', 'rosidl_buffer_backend_registry'] } From c7389d05578e9a7ebb7ffd0cb22418b37d2d3488 Mon Sep 17 00:00:00 2001 From: Thomas Chou Date: Fri, 28 Aug 2026 23:13:58 +0800 Subject: [PATCH 2/7] feat: validate ROS_DISTRO explicitly and enable CI push/dispatch workflows - Add validate_distro() in library_builder.py to check ROS_DISTRO against supported distributions (humble, iron, jazzy, kilted, lyrical, rolling) and fail with explicit error message instead of KeyError - Remove silent 'kilted' fallback in extra_script.py - Add push and workflow_dispatch triggers in .github/workflows/ci.yml - Set board_microros_distro = jazzy in ci/platformio.ini for platform test matrix --- .github/workflows/ci.yml | 4 ++++ ci/platformio.ini | 3 +++ extra_script.py | 4 ++-- microros_utils/library_builder.py | 12 ++++++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49106dd5..48020a5d 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,13 @@ name: CI on: + push: + branches: + - '**' pull_request: branches: - '**' + workflow_dispatch: schedule: - cron: '59 23 * * *' # every day at 23:59 UTC diff --git a/ci/platformio.ini b/ci/platformio.ini index 50f95ec2..e42d8fff 100644 --- a/ci/platformio.ini +++ b/ci/platformio.ini @@ -1,3 +1,6 @@ +[env] +board_microros_distro = jazzy + ; Humble test [env:portenta_h7_m7_humble] platform = ststm32 diff --git a/extra_script.py b/extra_script.py index 95196917..4d85b466 100644 --- a/extra_script.py +++ b/extra_script.py @@ -34,8 +34,8 @@ selected_board_meta = boards_metas[board] if board in boards_metas else "colcon.meta" -# Retrieve the required transport. Default kilted -microros_distro = global_env.BoardConfig().get("microros_distro", "kilted") +# Retrieve the required distro +microros_distro = global_env.BoardConfig().get("microros_distro", "") # Retrieve the required transport. Default serial microros_transport = global_env.BoardConfig().get("microros_transport", "serial") diff --git a/microros_utils/library_builder.py b/microros_utils/library_builder.py index e950c99e..f307d56f 100644 --- a/microros_utils/library_builder.py +++ b/microros_utils/library_builder.py @@ -54,11 +54,23 @@ def __init__(self, library_folder, packages_folder, distro, python_env): self.python_env = python_env self.env = None + def validate_distro(self): + if not self.distro or self.distro not in Sources.dev_environments: + valid_distros = ", ".join(sorted(Sources.dev_environments.keys())) + sys.stderr.write( + f"\n[ERROR] ROS_DISTRO / board_microros_distro is invalid or not defined: '{self.distro}'\n" + f"Please export ROS_DISTRO (e.g., 'export ROS_DISTRO=jazzy' or source your ROS 2 environment) " + f"or specify 'board_microros_distro' in platformio.ini.\n" + f"Supported ROS 2 distributions: {valid_distros}\n\n" + ) + sys.exit(1) + def run(self, meta, toolchain, user_meta = ""): if os.path.exists(self.library): print("micro-ROS already built") return + self.validate_distro() self.check_env() self.download_dev_environment() self.build_dev_environment() From 634041dfd6b04111584abdc2c2eb00086781c842 Mon Sep 17 00:00:00 2001 From: Thomas Chou Date: Sat, 29 Aug 2026 09:37:10 +0800 Subject: [PATCH 3/7] fix(builder): guard python_env activation with existence check for CI environments --- microros_utils/library_builder.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/microros_utils/library_builder.py b/microros_utils/library_builder.py index f307d56f..82ce0ff7 100644 --- a/microros_utils/library_builder.py +++ b/microros_utils/library_builder.py @@ -107,7 +107,8 @@ def build_dev_environment(self): print("Building micro-ROS dev dependencies") self.patch_dev_sources() - colcon_command = '. {} && colcon build --merge-install --packages-ignore-regex=.*_cpp --cmake-args -DPython3_EXECUTABLE=`which python` -DBUILD_TESTING=OFF'.format(self.python_env) + prefix = f". {self.python_env} && " if (self.python_env and os.path.exists(self.python_env)) else "" + colcon_command = '{}colcon build --merge-install --packages-ignore-regex=.*_cpp --cmake-args -DPython3_EXECUTABLE=`which python` -DBUILD_TESTING=OFF'.format(prefix) command = "cd {} && {}".format(self.dev_folder, colcon_command) result = run_cmd(command) @@ -179,7 +180,8 @@ def build_mcu_environment(self, meta_file, toolchain_file, user_meta = ""): self.patch_mcu_sources() common_meta_path = self.library_folder + '/metas/common.meta' - colcon_command = '. {} && colcon build --merge-install --packages-ignore-regex=.*_cpp --metas {} {} {} --cmake-args -DCMAKE_POSITION_INDEPENDENT_CODE:BOOL=OFF -DTHIRDPARTY=ON -DBUILD_SHARED_LIBS=OFF -DBUILD_TESTING=OFF -DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE={} -DPython3_EXECUTABLE=`which python`'.format(self.python_env, common_meta_path, meta_file, user_meta, toolchain_file) + prefix = f". {self.python_env} && " if (self.python_env and os.path.exists(self.python_env)) else "" + colcon_command = '{}colcon build --merge-install --packages-ignore-regex=.*_cpp --metas {} {} {} --cmake-args -DCMAKE_POSITION_INDEPENDENT_CODE:BOOL=OFF -DTHIRDPARTY=ON -DBUILD_SHARED_LIBS=OFF -DBUILD_TESTING=OFF -DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE={} -DPython3_EXECUTABLE=`which python`'.format(prefix, common_meta_path, meta_file, user_meta, toolchain_file) command = "cd {} && . {}/install/setup.sh && {}".format(self.mcu_folder, self.dev_folder, colcon_command) result = run_cmd(command, env=self.env) From 31a39a4d894235230f4fc287eef4b197fc97fcc7 Mon Sep 17 00:00:00 2001 From: Thomas Chou Date: Sat, 5 Sep 2026 11:36:56 +0800 Subject: [PATCH 4/7] fix(lyrical): fallback rclc branch to rolling instead of master to support rclc_timer_init_default2 --- microros_utils/repositories.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/microros_utils/repositories.py b/microros_utils/repositories.py index 760f1216..eb24d308 100644 --- a/microros_utils/repositories.py +++ b/microros_utils/repositories.py @@ -224,7 +224,7 @@ class Sources: Repository("micro-CDR", "https://github.com/eProsima/micro-CDR", "lyrical", "ros2"), Repository("Micro-XRCE-DDS-Client", "https://github.com/eProsima/Micro-XRCE-DDS-Client", "lyrical", "ros2"), Repository("rcl", "https://github.com/micro-ROS/rcl", "lyrical"), - Repository("rclc", "https://github.com/ros2/rclc", "lyrical", "master"), + Repository("rclc", "https://github.com/ros2/rclc", "lyrical", "rolling"), Repository("micro_ros_utilities", "https://github.com/micro-ROS/micro_ros_utilities", "lyrical", "rolling"), Repository("rcutils", "https://github.com/micro-ROS/rcutils", "lyrical"), Repository("micro_ros_msgs", "https://github.com/micro-ROS/micro_ros_msgs", "lyrical", "rolling"), From 66ca1b1440e393286ccf6b0d7f3bf0400d29c492 Mon Sep 17 00:00:00 2001 From: Thomas Chou Date: Sat, 5 Sep 2026 11:36:56 +0800 Subject: [PATCH 5/7] fix(ci): pin olimex_e407 to ststm32@~19.7.1 to avoid upstream _write regression platform = ststm32 (unpinned) resolves to 20.0.0 / framework-arduinoststm32 3.0.0, a breaking STM32duino release (HardwareSerial renamed to Uart, ArduinoCore-API deployed) whose new default syscall stubs leave '_write' undefined for libc_nano, failing the firmware.elf link step. Upstream nightly scheduled CI on main is already failing the same way, unrelated to the Lyrical changes in this PR. Pin the one affected env back to the last known-good ststm32 release to unblock CI. --- ci/platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/platformio.ini b/ci/platformio.ini index e42d8fff..8a6df9db 100644 --- a/ci/platformio.ini +++ b/ci/platformio.ini @@ -118,7 +118,7 @@ lib_deps = ../ [env:olimex_e407] -platform = ststm32 +platform = ststm32@~19.7.1 board = olimex_e407 framework = arduino board_microros_transport = serial From 330d8874ace432cb62f9eda7cd30b27ea65df129 Mon Sep 17 00:00:00 2001 From: Thomas Chou Date: Tue, 8 Sep 2026 22:46:31 +0800 Subject: [PATCH 6/7] fix(build): drop shell metacharacters from flags passed into the CMake toolchain env['CFLAGS'] / env['CCFLAGS'] are joined straight into CMAKE_C_FLAGS_INIT. CMake writes that into build.make, and /bin/sh parses it. ESP32 Arduino core 3.x ships -DCHIP_ADDRESS_RESOLVE_IMPL_INCLUDE_HEADER= whose angle brackets are redirection to the shell, so every compile in the micro-ROS build dies with "/bin/sh: 1: Syntax error: ";" unexpected". The failure names no file and no flag, and it hits every translation unit at once, so it reads like a broken toolchain rather than one bad argument. micro-ROS does not build the code those flags configure, so filtering them costs nothing. Found while building for ROS 2 Lyrical on an ESP32 with pioarduino (espressif32 53.03.13 / Arduino core 3.1.3). The official espressif32@7.1.1 (core 2.x) does not set the flag and is unaffected. --- extra_script.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/extra_script.py b/extra_script.py index 4d85b466..36346cc2 100644 --- a/extra_script.py +++ b/extra_script.py @@ -50,6 +50,20 @@ #### Library custom targets #### ################################ +def shell_safe_flags(flags): + """Drop flags carrying shell metacharacters before they reach CMake. + + These end up verbatim in CMAKE_C_FLAGS_INIT, which CMake writes into + build.make, which /bin/sh then parses. ESP32 Arduino core 3.x ships + -DCHIP_ADDRESS_RESOLVE_IMPL_INCLUDE_HEADER=; + the angle brackets are redirection to the shell, so every single compile + fails with "Syntax error: ';' unexpected" and the real cause is invisible. + micro-ROS does not build the code these flags are for, so dropping them + costs nothing. + """ + return [f for f in flags if not any(c in str(f) for c in "<>")] + + def clean_microros_callback(*args, **kwargs): library_path = main_path + '/libmicroros' build_path = main_path + '/build' @@ -104,8 +118,10 @@ def build_microros(*args, **kwargs): env['CC'], env['CXX'], env['AR'], - "{} {} -Wno-error=implicit-function-declaration -DCLOCK_MONOTONIC=0 -D'__attribute__(x)='".format(' '.join(env['CFLAGS']), ' '.join(env['CCFLAGS'])), - "{} {} -fno-rtti -DCLOCK_MONOTONIC=0 -D'__attribute__(x)='".format(' '.join(env['CXXFLAGS']), ' '.join(env['CCFLAGS'])) + "{} {} -Wno-error=implicit-function-declaration -DCLOCK_MONOTONIC=0 -D'__attribute__(x)='".format( + ' '.join(shell_safe_flags(env['CFLAGS'])), ' '.join(shell_safe_flags(env['CCFLAGS']))), + "{} {} -fno-rtti -DCLOCK_MONOTONIC=0 -D'__attribute__(x)='".format( + ' '.join(shell_safe_flags(env['CXXFLAGS'])), ' '.join(shell_safe_flags(env['CCFLAGS']))) ) python_env_path = env['PROJECT_CORE_DIR'] + "/penv/bin/activate" From cec76211d84f6ea03e2855d38d50a39a5988f383 Mon Sep 17 00:00:00 2001 From: Thomas Chou Date: Wed, 9 Sep 2026 14:40:29 +0800 Subject: [PATCH 7/7] fix(lyrical/rolling): build rcutils for platforms with struct atomics Every ESP32 build for Lyrical or Rolling stops in rcutils with src/testing/fault_injection.c:26:63: error: invalid initializer ros2/rcutils removed ATOMIC_VAR_INIT (#556, deprecated in C17 and gone in C23) and then picked the replacement initializer with an MSVC-only test (#587): #if defined(_WIN32) && !defined(__MINGW64__) static atomic_int_least64_t g_... = {-1}; // struct emulation #else static atomic_int_least64_t g_... = -1; // real C11 atomics #endif MSVC is not the only platform that emulates atomics with a struct. The xtensa newlib header defines ATOMIC_VAR_INIT(value) as { .__val = (value) }, so on every ESP32 the scalar arm is an invalid initializer. No CMake option avoids it: RCUTILS_NO_64_ATOMIC only adds src/atomic_64bits.c, and src/testing/fault_injection.c is compiled unconditionally. Patch the platform split down to the braced initializer, which is valid for a scalar as well as for the struct, so it is correct on both without the build having to know the platform. This joins the existing MCU source patches, and like them it only runs for lyrical and rolling; jazzy still carries the portable ATOMIC_VAR_INIT form and is untouched. The proper fix belongs in ros2/rcutils; until it lands there this keeps Lyrical and Rolling firmware building for xtensa. Verified against micro-ROS/rcutils 73d448e (lyrical): the fetched source arrives with the MSVC-only split, the patch rewrites it, fault_injection.c compiles and the firmware links. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L3KHsz78rqjcWPnVGcxj2x --- microros_utils/library_builder.py | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/microros_utils/library_builder.py b/microros_utils/library_builder.py index 82ce0ff7..c5e4e3b6 100644 --- a/microros_utils/library_builder.py +++ b/microros_utils/library_builder.py @@ -474,3 +474,47 @@ def patch_mcu_sources(self): lcc = "#include \n" + lcc.replace(target_lc, rep_lc) with open(lc_c, "w") as f: f.write(lcc) + + # 5. Patch rcutils fault_injection.c for platforms that emulate atomics + # with a struct. ros2/rcutils dropped ATOMIC_VAR_INIT (#556) and then + # chose the initializer with an MSVC-only test (#587): + # + # #if defined(_WIN32) && !defined(__MINGW64__) + # ... = {-1}; // struct emulation + # #else + # ... = -1; // real C11 atomics + # #endif + # + # MSVC is not the only one. xtensa/newlib -- every ESP32 -- defines + # ATOMIC_VAR_INIT(value) as { .__val = (value) }, so the scalar arm is + # an "invalid initializer" and every Lyrical/Rolling ESP32 build stops + # in rcutils. No CMake option avoids it: RCUTILS_NO_64_ATOMIC only adds + # src/atomic_64bits.c, and src/testing/fault_injection.c is compiled + # unconditionally. A braced initializer is valid for the scalar case + # too, so it covers both without needing to know the platform. + fault_injection_c = os.path.join( + self.mcu_src_folder, "rcutils", "src", "testing", "fault_injection.c") + if os.path.exists(fault_injection_c): + with open(fault_injection_c, "r") as f: + fic = f.read() + # Match the platform split itself, not "= {-1};" -- that fragment + # also appears inside the block being replaced, so testing for it + # would report every unpatched file as already done. + target_fi = "#if defined(_WIN32) && !defined(__MINGW64__)" + if target_fi in fic: + start = fic.index(target_fi) + # Take the comment that explains the split with it, otherwise the + # patched file keeps claiming that everywhere but MSVC has real + # C11 atomics -- which is the misconception being fixed. + stale = "// The initializer must match the definition of _Atomic in" + if stale in fic[:start]: + start = fic.index(stale) + end = fic.index("#endif", fic.index("= -1;", start)) + len("#endif") + fic = fic[:start] + ( + "// Patched by micro_ros_platformio: xtensa/newlib emulates atomics with\n" + "// a struct just as MSVC does, and the braced initializer is correct for\n" + "// the scalar case as well.\n" + "static atomic_int_least64_t g_rcutils_fault_injection_count = {-1};" + ) + fic[end:] + with open(fault_injection_c, "w") as f: + f.write(fic)