From 86b9a9bebed72f4bba60949ad93bed2ed09c1330 Mon Sep 17 00:00:00 2001 From: mhidalgo-bdai <144129882+mhidalgo-bdai@users.noreply.github.com> Date: Tue, 9 Jan 2024 18:31:02 -0300 Subject: [PATCH] Add `proto2ros` machinery (#44) Signed-off-by: Michel Hidalgo Co-authored-by: Gustavo Goretkin --- proto2ros/CMakeLists.txt | 35 ++ proto2ros/LICENSE | 21 + proto2ros/README.md | 5 + proto2ros/cmake/proto2ros_generate.cmake | 159 ++++++ .../cmake/proto2ros_vendor_package.cmake | 64 +++ proto2ros/cmake/rosidl_helpers.cmake | 81 +++ proto2ros/cmake/templates/mypy.ini.in | 10 + proto2ros/msg/Any.msg | 6 + proto2ros/msg/AnyProto.msg | 7 + proto2ros/msg/Bytes.msg | 6 + proto2ros/msg/List.msg | 6 + proto2ros/msg/Struct.msg | 6 + proto2ros/msg/StructEntry.msg | 8 + proto2ros/msg/Value.msg | 19 + proto2ros/package.xml | 42 ++ proto2ros/proto2ros-extras.cmake | 13 + proto2ros/proto2ros/__init__.py | 0 proto2ros/proto2ros/cli/__init__.py | 0 proto2ros/proto2ros/cli/generate.py | 164 ++++++ proto2ros/proto2ros/compatibility.py | 10 + proto2ros/proto2ros/configuration/__init__.py | 94 ++++ .../proto2ros/configuration/default.yaml | 27 + proto2ros/proto2ros/conversions/__init__.py | 5 + proto2ros/proto2ros/conversions/basic.py | 447 ++++++++++++++++ proto2ros/proto2ros/dependencies.py | 63 +++ proto2ros/proto2ros/descriptors/__init__.py | 0 proto2ros/proto2ros/descriptors/sources.py | 56 ++ proto2ros/proto2ros/descriptors/types.py | 25 + proto2ros/proto2ros/descriptors/utilities.py | 112 ++++ proto2ros/proto2ros/equivalences.py | 480 ++++++++++++++++++ proto2ros/proto2ros/output/__init__.py | 0 proto2ros/proto2ros/output/interfaces.py | 67 +++ proto2ros/proto2ros/output/python.py | 151 ++++++ .../proto2ros/output/templates/__init__.py | 0 .../output/templates/conversions.py.jinja | 289 +++++++++++ proto2ros/proto2ros/utilities.py | 163 ++++++ proto2ros/setup.cfg | 4 + proto2ros_tests/CMakeLists.txt | 58 +++ proto2ros_tests/LICENSE | 21 + proto2ros_tests/config/overlay.yaml | 11 + proto2ros_tests/package.xml | 34 ++ proto2ros_tests/proto/CMakeLists.txt | 25 + .../proto/bosdyn/api/geometry.proto | 367 +++++++++++++ proto2ros_tests/proto/test.proto | 197 +++++++ proto2ros_tests/proto2ros_tests/__init__.py | 0 .../proto2ros_tests/manual_conversions.py | 90 ++++ proto2ros_tests/test/generated/AnyCommand.msg | 3 + .../test/generated/AnyCommandJump.msg | 4 + .../generated/AnyCommandOneOfCommands.msg | 9 + .../test/generated/AnyCommandWalk.msg | 6 + proto2ros_tests/test/generated/CameraInfo.msg | 17 + .../generated/CameraInfoDistortionModel.msg | 3 + .../CameraInfoDistortionModelType.msg | 7 + proto2ros_tests/test/generated/Diagnostic.msg | 8 + .../generated/DiagnosticAttributesEntry.msg | 3 + .../test/generated/DiagnosticSeverity.msg | 7 + proto2ros_tests/test/generated/Direction.msg | 7 + .../test/generated/Displacement.msg | 6 + proto2ros_tests/test/generated/Error.msg | 6 + proto2ros_tests/test/generated/Fragment.msg | 6 + proto2ros_tests/test/generated/HTTP.msg | 3 + proto2ros_tests/test/generated/HTTPMethod.msg | 8 + .../test/generated/HTTPRequest.msg | 8 + .../test/generated/HTTPResponse.msg | 8 + proto2ros_tests/test/generated/HTTPStatus.msg | 8 + proto2ros_tests/test/generated/Map.msg | 3 + .../test/generated/MapFragment.msg | 5 + .../test/generated/MapSubmapsEntry.msg | 3 + proto2ros_tests/test/generated/Matrix.msg | 5 + .../test/generated/MotionRequest.msg | 6 + .../test/generated/MotionRequestDirection.msg | 8 + .../test/generated/RemoteExecutionRequest.msg | 8 + .../test/generated/RemoteExecutionResult.msg | 7 + .../generated/RemoteExecutionResultError.msg | 6 + proto2ros_tests/test/test_proto2ros.py | 241 +++++++++ 75 files changed, 3867 insertions(+) create mode 100644 proto2ros/CMakeLists.txt create mode 100644 proto2ros/LICENSE create mode 100644 proto2ros/README.md create mode 100644 proto2ros/cmake/proto2ros_generate.cmake create mode 100644 proto2ros/cmake/proto2ros_vendor_package.cmake create mode 100644 proto2ros/cmake/rosidl_helpers.cmake create mode 100644 proto2ros/cmake/templates/mypy.ini.in create mode 100644 proto2ros/msg/Any.msg create mode 100644 proto2ros/msg/AnyProto.msg create mode 100644 proto2ros/msg/Bytes.msg create mode 100644 proto2ros/msg/List.msg create mode 100644 proto2ros/msg/Struct.msg create mode 100644 proto2ros/msg/StructEntry.msg create mode 100644 proto2ros/msg/Value.msg create mode 100644 proto2ros/package.xml create mode 100644 proto2ros/proto2ros-extras.cmake create mode 100644 proto2ros/proto2ros/__init__.py create mode 100644 proto2ros/proto2ros/cli/__init__.py create mode 100644 proto2ros/proto2ros/cli/generate.py create mode 100644 proto2ros/proto2ros/compatibility.py create mode 100644 proto2ros/proto2ros/configuration/__init__.py create mode 100644 proto2ros/proto2ros/configuration/default.yaml create mode 100644 proto2ros/proto2ros/conversions/__init__.py create mode 100644 proto2ros/proto2ros/conversions/basic.py create mode 100644 proto2ros/proto2ros/dependencies.py create mode 100644 proto2ros/proto2ros/descriptors/__init__.py create mode 100644 proto2ros/proto2ros/descriptors/sources.py create mode 100644 proto2ros/proto2ros/descriptors/types.py create mode 100644 proto2ros/proto2ros/descriptors/utilities.py create mode 100644 proto2ros/proto2ros/equivalences.py create mode 100644 proto2ros/proto2ros/output/__init__.py create mode 100644 proto2ros/proto2ros/output/interfaces.py create mode 100644 proto2ros/proto2ros/output/python.py create mode 100644 proto2ros/proto2ros/output/templates/__init__.py create mode 100644 proto2ros/proto2ros/output/templates/conversions.py.jinja create mode 100644 proto2ros/proto2ros/utilities.py create mode 100644 proto2ros/setup.cfg create mode 100644 proto2ros_tests/CMakeLists.txt create mode 100644 proto2ros_tests/LICENSE create mode 100644 proto2ros_tests/config/overlay.yaml create mode 100644 proto2ros_tests/package.xml create mode 100644 proto2ros_tests/proto/CMakeLists.txt create mode 100644 proto2ros_tests/proto/bosdyn/api/geometry.proto create mode 100644 proto2ros_tests/proto/test.proto create mode 100644 proto2ros_tests/proto2ros_tests/__init__.py create mode 100644 proto2ros_tests/proto2ros_tests/manual_conversions.py create mode 100644 proto2ros_tests/test/generated/AnyCommand.msg create mode 100644 proto2ros_tests/test/generated/AnyCommandJump.msg create mode 100644 proto2ros_tests/test/generated/AnyCommandOneOfCommands.msg create mode 100644 proto2ros_tests/test/generated/AnyCommandWalk.msg create mode 100644 proto2ros_tests/test/generated/CameraInfo.msg create mode 100644 proto2ros_tests/test/generated/CameraInfoDistortionModel.msg create mode 100644 proto2ros_tests/test/generated/CameraInfoDistortionModelType.msg create mode 100644 proto2ros_tests/test/generated/Diagnostic.msg create mode 100644 proto2ros_tests/test/generated/DiagnosticAttributesEntry.msg create mode 100644 proto2ros_tests/test/generated/DiagnosticSeverity.msg create mode 100644 proto2ros_tests/test/generated/Direction.msg create mode 100644 proto2ros_tests/test/generated/Displacement.msg create mode 100644 proto2ros_tests/test/generated/Error.msg create mode 100644 proto2ros_tests/test/generated/Fragment.msg create mode 100644 proto2ros_tests/test/generated/HTTP.msg create mode 100644 proto2ros_tests/test/generated/HTTPMethod.msg create mode 100644 proto2ros_tests/test/generated/HTTPRequest.msg create mode 100644 proto2ros_tests/test/generated/HTTPResponse.msg create mode 100644 proto2ros_tests/test/generated/HTTPStatus.msg create mode 100644 proto2ros_tests/test/generated/Map.msg create mode 100644 proto2ros_tests/test/generated/MapFragment.msg create mode 100644 proto2ros_tests/test/generated/MapSubmapsEntry.msg create mode 100644 proto2ros_tests/test/generated/Matrix.msg create mode 100644 proto2ros_tests/test/generated/MotionRequest.msg create mode 100644 proto2ros_tests/test/generated/MotionRequestDirection.msg create mode 100644 proto2ros_tests/test/generated/RemoteExecutionRequest.msg create mode 100644 proto2ros_tests/test/generated/RemoteExecutionResult.msg create mode 100644 proto2ros_tests/test/generated/RemoteExecutionResultError.msg create mode 100644 proto2ros_tests/test/test_proto2ros.py diff --git a/proto2ros/CMakeLists.txt b/proto2ros/CMakeLists.txt new file mode 100644 index 0000000..0d5a014 --- /dev/null +++ b/proto2ros/CMakeLists.txt @@ -0,0 +1,35 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. +cmake_minimum_required(VERSION 3.8) +project(proto2ros) + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_compile_options(-Wall -Wextra -Wpedantic) +endif() + +# find dependencies +find_package(ament_cmake REQUIRED) +find_package(ament_cmake_python REQUIRED) +find_package(rosidl_default_generators REQUIRED) + +rosidl_generate_interfaces(${PROJECT_NAME} + msg/Any.msg msg/AnyProto.msg msg/Bytes.msg msg/List.msg + msg/Struct.msg msg/StructEntry.msg msg/Value.msg +) + +include(cmake/rosidl_helpers.cmake) +rosidl_generated_python_package_add( + ${PROJECT_NAME}_additional_modules + PACKAGES ${PROJECT_NAME} + DESTINATION ${PROJECT_NAME} +) + +install( + DIRECTORY cmake + DESTINATION share/${PROJECT_NAME} +) + +ament_export_dependencies(builtin_interfaces) +ament_export_dependencies(rosidl_default_runtime) +ament_export_dependencies(std_msgs) + +ament_package(CONFIG_EXTRAS "proto2ros-extras.cmake") diff --git a/proto2ros/LICENSE b/proto2ros/LICENSE new file mode 100644 index 0000000..aa696ca --- /dev/null +++ b/proto2ros/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Boston Dynamics AI Institute + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/proto2ros/README.md b/proto2ros/README.md new file mode 100644 index 0000000..1dfa93a --- /dev/null +++ b/proto2ros/README.md @@ -0,0 +1,5 @@ +# Protobuf to ROS 2 interoperability + +`proto2ros` helps maintain an interoperability layer between Protobuf dependent and ROS 2 aware code by generating equivalent ROS 2 message definitions given source Protobuf message definitions, as well bi-directional conversion APIs in relevant languages (such as Python). To date, Protobuf syntax versions 2 and 3 are supported but only syntax version 3 has been extensively tested. + +Continue on to the [`ros_utilities` wiki](https://github.com/bdaiinstitute/ros_utilities/wiki) for further reference. diff --git a/proto2ros/cmake/proto2ros_generate.cmake b/proto2ros/cmake/proto2ros_generate.cmake new file mode 100644 index 0000000..c679566 --- /dev/null +++ b/proto2ros/cmake/proto2ros_generate.cmake @@ -0,0 +1,159 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +# Generates Protobuf <-> ROS 2 interoperability interfaces. +# +# :param target: the target name for the generation process, so it can be depended on. +# :param PACKAGE_NAME: name of the package that will host the generated interfaces. +# Defaults to the current project name. +# :param PROTOS: Protobuf message files to generate interoperability interfaces for. +# These are compiled to a single Protobuf descriptor file for further processing. +# :param IMPORT_DIRS: optional import paths to pass to protoc. Only used when PROTOS +# are provided. If none is given, parent directories of PROTOS are used instead. +# :param PROTO_DESCRIPTORS: Protobuf descriptor files for the Protobuf messages +# that the generation process will produce interoperability interfaces for. +# :param CONFIG_FILE: optional base configuration file for the generation process. +# :param CONFIG_OVERLAYS: optional configuration file overlays to be applied sequentially +# over the (provided or default) base configuration file. +# :param INTERFACES_OUT_VAR: the name of the variable to yield ROS 2 interface tuples. +# Defaults to the target name with an `_interfaces` suffix. +# :param PYTHON_OUT_VAR: the name of the variable to yield generated Python sources. +# Defaults to the target name with a `_python_sources` suffix. +# :param APPEND_PYTHONPATH: optional paths to append to the PYTHONPATH that applies +# to the generation process. +# :param NO_LINT: if provided, no lint tests are added for generated code. +function(proto2ros_generate target) + cmake_parse_arguments( + ARG "NO_LINT" + "PACKAGE_NAME;CONFIG_FILE;INTERFACES_OUT_VAR;PYTHON_OUT_VAR" + "PROTOS;PROTO_DESCRIPTORS;IMPORT_DIRS;CONFIG_OVERLAYS;APPEND_PYTHONPATH" ${ARGN}) + if(NOT ARG_PACKAGE_NAME) + set(ARG_PACKAGE_NAME ${PROJECT_NAME}) + endif() + if(NOT ARG_INTERFACES_OUT_VAR) + set(ARG_INTERFACES_OUT_VAR ${target}_interfaces) + endif() + if(NOT ARG_PYTHON_OUT_VAR) + set(ARG_INTERFACES_OUT_VAR ${target}_python_sources) + endif() + list(APPEND ARG_APPEND_PYTHONPATH "${PROJECT_SOURCE_DIR}") + string(REPLACE ";" ":" APPEND_PYTHONPATH "${ARG_APPEND_PYTHONPATH}") + + set(BASE_PATH "${CMAKE_CURRENT_BINARY_DIR}/proto2ros_generate") + set(OUTPUT_PATH "${BASE_PATH}/${ARG_PACKAGE_NAME}") + file(REMOVE_RECURSE "${OUTPUT_PATH}") + file(MAKE_DIRECTORY "${OUTPUT_PATH}") + + foreach(path ${ARG_PROTO_DESCRIPTORS}) + get_filename_component(path "${path}" ABSOLUTE) + list(APPEND PROTO_DESCRIPTORS ${path}) + endforeach() + + if(ARG_PROTOS) + set(protoc_options --include_source_info) + set(proto_descriptor "${CMAKE_CURRENT_BINARY_DIR}/${target}.desc") + foreach(proto ${ARG_PROTOS}) + get_filename_component(proto_path "${proto}" ABSOLUTE) + if(IS_DIRECTORY "${proto_path}") + file(GLOB_RECURSE nested_files "${proto_path}" *.proto) + if(NOT ARG_IMPORT_DIRS) + list(APPEND protoc_options "-I${proto_path}") + endif() + list(APPEND proto_files ${nested_files}) + else() + get_filename_component(proto_dir "${proto_path}" DIRECTORY) + if(NOT ARG_IMPORT_DIRS) + list(APPEND protoc_options "-I${proto_dir}") + endif() + list(APPEND proto_files "${proto_path}") + endif() + endforeach() + foreach(path ${ARG_IMPORT_DIRS}) + get_filename_component(path "${path}" ABSOLUTE) + list(APPEND protoc_options "-I${path}") + endforeach() + list(REMOVE_DUPLICATES protoc_options) + + # Generate the implicit descriptor file at configuration time + # so that configuration code below can run. + get_executable_path(PROTOC_EXECUTABLE protobuf::protoc CONFIGURE) + execute_process( + COMMAND + ${PROTOC_EXECUTABLE} ${protoc_options} -o${proto_descriptor} ${proto_files} + COMMAND_ERROR_IS_FATAL ANY + ) + + add_custom_command( + OUTPUT ${proto_descriptor} + COMMAND ${PROTOC_EXECUTABLE} ${protoc_options} -o${proto_descriptor} ${proto_files} + DEPENDS ${proto_files} + COMMENT "Compile descriptor from .proto files" + VERBATIM + ) + list(APPEND PROTO_DESCRIPTORS ${proto_descriptor}) + endif() + + if(NOT PROTO_DESCRIPTORS) + message(FATAL_ERROR "No Protobuf descriptors to process") + endif() + + if(ARG_CONFIG_FILE) + get_filename_component(ARG_CONFIG_FILE "${ARG_CONFIG_FILE}" ABSOLUTE) + list(APPEND PROTO2ROS_GENERATE_OPTIONS "-c" "${ARG_CONFIG_FILE}") + endif() + foreach(overlay ${ARG_CONFIG_OVERLAYS}) + get_filename_component(overlay "${overlay}" ABSOLUTE) + list(APPEND PROTO2ROS_GENERATE_OPTIONS "-a" "${overlay}") + endforeach() + list(APPEND PROTO2ROS_GENERATE_OPTIONS "-m" "${OUTPUT_PATH}/manifest.txt") + list(APPEND PROTO2ROS_GENERATE_OPTIONS "-O" "${OUTPUT_PATH}") + + # As we cannot deduce what files will result from the generation process ahead of time, + # we perform a dry run at configuration time to determine these files. Build will be + # forced to fail until reconfiguration whenever the set of output files changes. Note + # that messages are also generated, as the rosidl pipeline requires them to exist at + # configuration time. + get_executable_path(PYTHON_EXECUTABLE Python3::Interpreter CONFIGURE) + execute_process( + COMMAND + ${CMAKE_COMMAND} -E env + "PYTHONPATH=${APPEND_PYTHONPATH}:$ENV{PYTHONPATH}" + "PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python" + ${PYTHON_EXECUTABLE} -m proto2ros.cli.generate --dry --force-message-gen + ${PROTO2ROS_GENERATE_OPTIONS} ${ARG_PACKAGE_NAME} ${PROTO_DESCRIPTORS} + COMMAND_ERROR_IS_FATAL ANY + ) + file(STRINGS "${OUTPUT_PATH}/manifest.txt" output_files) + file(RENAME "${OUTPUT_PATH}/manifest.txt" "${OUTPUT_PATH}/manifest.orig.txt") + + add_custom_command( + OUTPUT ${output_files} + COMMAND + ${CMAKE_COMMAND} -E env "PYTHONPATH=${APPEND_PYTHONPATH}:$ENV{PYTHONPATH}" "PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python" + ${PYTHON_EXECUTABLE} -m proto2ros.cli.generate ${PROTO2ROS_GENERATE_OPTIONS} ${ARG_PACKAGE_NAME} ${PROTO_DESCRIPTORS} + COMMAND ${CMAKE_COMMAND} -E compare_files "${OUTPUT_PATH}/manifest.txt" "${OUTPUT_PATH}/manifest.orig.txt" + COMMENT "Generate Protobuf <-> ROS interop interfaces (must reconfigure if the cardinality of the output set changes)" + DEPENDS ${PROTO_DESCRIPTORS} + VERBATIM + ) + add_custom_target(${target} DEPENDS ${output_files}) + + set(interface_files ${output_files}) + list(FILTER interface_files INCLUDE REGEX ".*\.msg$") + string(REPLACE "${OUTPUT_PATH}/" "${OUTPUT_PATH}:" interface_tuples "${interface_files}") + set(python_sources ${output_files}) + list(FILTER python_sources INCLUDE REGEX ".*\.py$") + + if(BUILD_TESTING AND NOT ARG_NO_LINT AND ament_cmake_mypy_FOUND) + set(MYPY_PATH "${APPEND_PYTHONPATH}:$ENV{PYTHONPATH}") + configure_file( + "${proto2ros_DIR}/templates/mypy.ini.in" + "${BASE_PATH}/mypy.ini" @ONLY + ) + ament_mypy(${python_sources} + TESTNAME ${target}_mypy + CONFIG_FILE "${BASE_PATH}/mypy.ini" + ) + endif() + set(${ARG_INTERFACES_OUT_VAR} ${interface_tuples} PARENT_SCOPE) + set(${ARG_PYTHON_OUT_VAR} ${python_sources} PARENT_SCOPE) +endfunction() diff --git a/proto2ros/cmake/proto2ros_vendor_package.cmake b/proto2ros/cmake/proto2ros_vendor_package.cmake new file mode 100644 index 0000000..9408aa5 --- /dev/null +++ b/proto2ros/cmake/proto2ros_vendor_package.cmake @@ -0,0 +1,64 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +# Vendors a package providing Protobuf messages, adding ROS 2 interoperability interfaces. +# +# :param target: the target name for the process, so it can be depended on. +# :param PACKAGE_NAME: name of the package that will host the generated interfaces. +# Defaults to the current project name. +# :param PROTOS: Protobuf message files to generate interoperability interfaces for. +# These are compiled to a single Protobuf descriptor file for further processing. +# :param IMPORT_DIRS: optional import paths to pass to protoc. Only used when PROTOS +# are provided. If none is given, parent directories of PROTOS are used instead. +# :param CONFIG_OVERLAYS: optional configuration file overlays to be applied sequentially +# over the default base configuration file. +# +macro(proto2ros_vendor_package target) + set(options NO_LINT) + set(one_value_keywords PACKAGE_NAME) + set(multi_value_keywords PROTOS IMPORT_DIRS CONFIG_OVERLAYS ROS_DEPENDENCIES PYTHON_MODULES PYTHON_PACKAGES) + cmake_parse_arguments(ARG "${options}" "${one_value_keywords}" "${multi_value_keywords}" ${ARGN}) + + if(NOT ARG_PACKAGE_NAME) + set(ARG_PACKAGE_NAME ${PROJECT_NAME}) + endif() + + set(proto2ros_generate_OPTIONS) + if(ARG_NO_LINT) + list(APPEND proto2ros_generate_OPTIONS NO_LINT) + endif() + + proto2ros_generate( + ${target}_messages_gen + PROTOS ${ARG_PROTOS} + IMPORT_DIRS ${ARG_IMPORT_DIRS} + PACKAGE_NAME ${ARG_PACKAGE_NAME} + CONFIG_OVERLAYS ${ARG_CONFIG_OVERLAYS} + INTERFACES_OUT_VAR ros_messages + PYTHON_OUT_VAR py_sources + ${proto2ros_generate_OPTIONS} + ) + + set(rosidl_generate_interfaces_OPTIONS) + if(NOT ARG_NO_LINT) + list(APPEND rosidl_generate_interfaces_OPTIONS ADD_LINTER_TESTS) + endif() + + rosidl_generate_interfaces( + ${target} ${ros_messages} + DEPENDENCIES ${ARG_ROS_DEPENDENCIES} builtin_interfaces proto2ros + ${rosidl_generate_interfaces_OPTIONS} + ) + add_dependencies(${target} ${target}_messages_gen) + + get_filename_component(package_path "${ARG_PACKAGE_NAME}" ABSOLUTE) + if(EXISTS "${package_path}/__init__.py") + list(APPEND ARG_PYTHON_PACKAGES ${ARG_PACKAGE_NAME}) + endif() + + rosidl_generated_python_package_add( + ${target}_additional_modules + MODULES ${ARG_PYTHON_MODULES} ${py_sources} + PACKAGES ${ARG_PYTHON_PACKAGES} + DESTINATION ${target} + ) +endmacro() diff --git a/proto2ros/cmake/rosidl_helpers.cmake b/proto2ros/cmake/rosidl_helpers.cmake new file mode 100644 index 0000000..b7ce862 --- /dev/null +++ b/proto2ros/cmake/rosidl_helpers.cmake @@ -0,0 +1,81 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +# Adds Python sources to an existing rosidl generated Python package. +# +# :param target: the target name for the file transfer, so it can be depended on. +# :param MODULES: Python modules (i.e. files) to be added. These will copied +# under the rosidl generated Python package. No directory structure will +# be kept. +# :param PACKAGES: Python packages (i.e. directories) to be added. Their content +# will be copied under the rosidl generated Python Package. Their directory +# structure will be kept. +# :param DESTINATION: the rosidl_generate_interfaces target that the destination +# Python package is associated with. Defaults to the current project name. +function(rosidl_generated_python_package_add target) + cmake_parse_arguments(ARG "" "DESTINATION" "MODULES;PACKAGES" ${ARGN}) + if(NOT ARG_DESTINATION) + set(ARG_DESTINATION ${PROJECT_NAME}) + endif() + if(NOT ARG_MODULES AND NOT ARG_PACKAGES) + message(FATAL_ERROR "No modules nor packages to add") + endif() + set(OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/rosidl_generator_py/${ARG_DESTINATION}") + + if(ARG_MODULES) + unset(input_files) + unset(output_files) + foreach(module ${ARG_MODULES}) + get_filename_component(module_name "${module}" NAME) + list(APPEND output_files "${OUTPUT_DIR}/${module_name}") + get_filename_component(module_path "${module}" ABSOLUTE) + list(APPEND input_files "${module_path}") + endforeach() + add_custom_command( + OUTPUT ${output_files} + COMMAND ${CMAKE_COMMAND} -E copy ${input_files} ${OUTPUT_DIR}/. + DEPENDS ${input_files} + ) + list(APPEND OUTPUT_FILES ${output_files}) + endif() + + if(ARG_PACKAGES) + unset(input_files) + unset(output_files) + foreach(package ${ARG_PACKAGES}) + get_filename_component(package_path "${package}" ABSOLUTE) + file(GLOB_RECURSE package_files "${package_path}" *.py) + foreach(input_file ${package_files}) + get_filename_component(module_name "${module}" NAME) + file(RELATIVE_PATH output_file ${package_path} "${input_file}") + list(APPEND output_files "${OUTPUT_DIR}/${output_file}") + endforeach() + list(APPEND input_dirs "${package_path}") + list(APPEND input_files "${package_files}") + endforeach() + add_custom_command( + OUTPUT ${output_files} + COMMAND ${CMAKE_COMMAND} -E copy_directory ${input_dirs} ${OUTPUT_DIR}/. + DEPENDS ${input_files} ${input_dirs} + ) + list(APPEND OUTPUT_FILES ${output_files}) + endif() + + add_custom_target(${target} ALL DEPENDS ${OUTPUT_FILES}) + add_dependencies(${target} ${ARG_DESTINATION}) +endfunction() + +# Exports relevant variables to setup tests that depend on rosidl generated artifacts. +# +# :param LIBRARY_DIRS: the name of the variable to yield the paths +# where generated shared library may be found. +# :param ENV: the name of the variable to yield relevant environment +# variable values pointing to generated artifacts (such as PYTHONPATH). +function(get_rosidl_generated_interfaces_test_setup) + cmake_parse_arguments(ARG "" "LIBRARY_DIRS;ENV" "" ${ARGN}) + if(ARG_LIBRARY_DIRS) + set(${ARG_LIBRARY_DIRS} "${CMAKE_CURRENT_BINARY_DIR}" PARENT_SCOPE) + endif() + if(ARG_ENV) + set(${ARG_ENV} "PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR}/rosidl_generator_py" PARENT_SCOPE) + endif() +endfunction() diff --git a/proto2ros/cmake/templates/mypy.ini.in b/proto2ros/cmake/templates/mypy.ini.in new file mode 100644 index 0000000..dfd375d --- /dev/null +++ b/proto2ros/cmake/templates/mypy.ini.in @@ -0,0 +1,10 @@ +# Generated from cmake/templates/mypy.ini.in in the proto2ros package. +[mypy] +ignore_missing_imports = true +mypy_path = "@MYPY_PATH@" +# Ignore missing type annotations, mainly coming from ROS 2 imports. +disable_error_code = var-annotated + +[mypy-google.*] +# Ignore google.* modules explicitly to avoid missing type hints errors. +ignore_missing_imports = true diff --git a/proto2ros/msg/Any.msg b/proto2ros/msg/Any.msg new file mode 100644 index 0000000..e568cd2 --- /dev/null +++ b/proto2ros/msg/Any.msg @@ -0,0 +1,6 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +# A dynamically typed ROS 2 message. + +string type_name # ROS 2 message type name +uint8[] value # Serialized ROS 2 message instance diff --git a/proto2ros/msg/AnyProto.msg b/proto2ros/msg/AnyProto.msg new file mode 100644 index 0000000..881080c --- /dev/null +++ b/proto2ros/msg/AnyProto.msg @@ -0,0 +1,7 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +# A dynamically typed Protobuf message. Equivalent to the google.protobuf.Any message. +# See https://protobuf.dev/reference/protobuf/google.protobuf/#any for further reference. + +string type_url # Protobuf message type URL. +uint8[] value # Packed Protobuf message instance. diff --git a/proto2ros/msg/Bytes.msg b/proto2ros/msg/Bytes.msg new file mode 100644 index 0000000..a835697 --- /dev/null +++ b/proto2ros/msg/Bytes.msg @@ -0,0 +1,6 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +# A binary blob. Equivalent to the google.protobuf.BytesValue message, also used to map repeated bytes fields to +# the ROS 2 domain. See https://protobuf.dev/reference/protobuf/google.protobuf/#bytes-value for further reference. + +uint8[] data diff --git a/proto2ros/msg/List.msg b/proto2ros/msg/List.msg new file mode 100644 index 0000000..d012d59 --- /dev/null +++ b/proto2ros/msg/List.msg @@ -0,0 +1,6 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +# A list of dynamically typed values. Equivalent to the google.protobuf.ListValue message. +# See https://protobuf.dev/reference/protobuf/google.protobuf/#list-value for further reference. + +proto2ros/Value[] values diff --git a/proto2ros/msg/Struct.msg b/proto2ros/msg/Struct.msg new file mode 100644 index 0000000..23d382a --- /dev/null +++ b/proto2ros/msg/Struct.msg @@ -0,0 +1,6 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +# A structured data value with dynamically typed fields. Equivalent to the google.protobuf.Struct message. +# See https://protobuf.dev/reference/protobuf/google.protobuf/#struct for further reference. + +proto2ros/StructEntry[] fields diff --git a/proto2ros/msg/StructEntry.msg b/proto2ros/msg/StructEntry.msg new file mode 100644 index 0000000..8d16464 --- /dev/null +++ b/proto2ros/msg/StructEntry.msg @@ -0,0 +1,8 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +# Key-value entries in the proto2ros/Struct ROS 2 message. Equivalent to +# the auxiliary Protobuf message used over-the-wire representation of the +# google.protobuf.Struct message. + +string key +proto2ros/Value value diff --git a/proto2ros/msg/Value.msg b/proto2ros/msg/Value.msg new file mode 100644 index 0000000..eefd497 --- /dev/null +++ b/proto2ros/msg/Value.msg @@ -0,0 +1,19 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +# A dynamically typed (scalar or composite) value. Equivalent to the google.protobuf.Value message. +# See https://protobuf.dev/reference/protobuf/google.protobuf/#value for further reference. + +int8 NO_VALUE_SET=0 +int8 NUMBER_VALUE_SET=1 +int8 STRING_VALUE_SET=2 +int8 BOOL_VALUE_SET=3 +int8 STRUCT_VALUE_SET=4 +int8 LIST_VALUE_SET=5 + +int8 kind 0 + +float64 number_value +string string_value +bool bool_value +proto2ros/Any struct_value # is proto2ros/Struct +proto2ros/Any list_value # is proto2ros/List diff --git a/proto2ros/package.xml b/proto2ros/package.xml new file mode 100644 index 0000000..74d9776 --- /dev/null +++ b/proto2ros/package.xml @@ -0,0 +1,42 @@ + + + + + proto2ros + 0.1.0 + Protobuf to ROS 2 interoperability interfaces + BD AI Institute + MIT + + ament_cmake + ament_cmake_python + rosidl_default_generators + rosidl_default_generators + ament_cmake_mypy + + protobuf + protobuf-dev + rosidl_adapter + + builtin_interfaces + rosidl_default_runtime + std_msgs + + python3-inflection + python3-jinja2 + python3-multipledispatch + python3-numpy + python3-networkx + python3-protobuf + python3-yaml + + ament_cmake_pytest + + rosidl_interface_packages + + + ament_cmake + + diff --git a/proto2ros/proto2ros-extras.cmake b/proto2ros/proto2ros-extras.cmake new file mode 100644 index 0000000..c567e1a --- /dev/null +++ b/proto2ros/proto2ros-extras.cmake @@ -0,0 +1,13 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. +include("${proto2ros_DIR}/rosidl_helpers.cmake") + +find_package(Python3 REQUIRED) +find_package(Protobuf REQUIRED) +if(BUILD_TESTING) + find_package(ament_cmake_mypy QUIET) +endif() +include("${proto2ros_DIR}/proto2ros_generate.cmake") + +find_package(builtin_interfaces REQUIRED) +find_package(rosidl_default_generators REQUIRED) +include("${proto2ros_DIR}/proto2ros_vendor_package.cmake") diff --git a/proto2ros/proto2ros/__init__.py b/proto2ros/proto2ros/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/proto2ros/proto2ros/cli/__init__.py b/proto2ros/proto2ros/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/proto2ros/proto2ros/cli/generate.py b/proto2ros/proto2ros/cli/generate.py new file mode 100644 index 0000000..0b4e79a --- /dev/null +++ b/proto2ros/proto2ros/cli/generate.py @@ -0,0 +1,164 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. +import argparse +import collections +import importlib.resources +import os +import pathlib +import sys +import textwrap +from typing import List + +from google.protobuf.descriptor_pb2 import FileDescriptorProto + +from proto2ros.configuration import Configuration +from proto2ros.dependencies import fix_dependency_cycles +from proto2ros.descriptors.sources import read_source_descriptors +from proto2ros.equivalences import Equivalence, all_message_specifications, extract_equivalences +from proto2ros.output.interfaces import dump_message_specification, which_message_specification +from proto2ros.output.python import ( + dump_conversions_python_module, + to_pb2_python_module_name, + to_ros_python_module_name, + which_conversions_python_module, +) + + +def do_generate(args: argparse.Namespace) -> int: + # Fetch baseline configuration. + config = Configuration.from_file(args.config_file) + + # Read source descriptors for processing. + source_descriptors: List[FileDescriptorProto] = [] + for descriptor_file in args.descriptor_files: + for source_descriptor in read_source_descriptors(descriptor_file): + # Map all Protobuf packages in source descriptors to target ROS package. + config.package_mapping[source_descriptor.package] = args.package_name + if not config.skip_implicit_imports: + # Collect all .proto source files (including those imported). + source_paths = [source_descriptor.name, *source_descriptor.dependency] + # Add corresponding *_pb2 Python modules to configured Python imports. + config.python_imports.update(map(to_pb2_python_module_name, source_paths)) + # Add target ROS package to configured Python imports. + config.python_imports.add(to_ros_python_module_name(args.package_name)) + source_descriptors.append(source_descriptor) + + # Apply overlays to configuration. + for overlay_file in args.config_overlay_files: + config.update(**Configuration.updates_from_file(overlay_file)) + + # Compute Protobuf <-> ROS equivalences. + equivalences: List[Equivalence] = [] + for source_descriptor in source_descriptors: + equivalences.extend(extract_equivalences(source_descriptor, config)) + + # Extract annotated message specifications from equivalences. + message_specifications = list(all_message_specifications(equivalences)) + fix_dependency_cycles(message_specifications, quiet=args.dry) + + # Ensure no name clashes between message specifications. + all_known_message_specifications = list(message_specifications) + all_known_message_specifications.extend(config.known_message_specifications.values()) + message_types = [spec.base_type for spec in all_known_message_specifications] + message_type_instances = collections.Counter(message_types) + if len(message_type_instances) != len(message_types): + unique_message_type_instances = collections.Counter(set(message_type_instances)) + message_type_duplicates = list(message_type_instances - unique_message_type_instances) + print("Found duplicate message types (name clashes?):", file=sys.stderr) + print( + textwrap.indent( + "\n".join( + sorted( + [ + f"{spec.annotations['proto-type']} maps to {spec.base_type}" + for spec in all_known_message_specifications + if spec.base_type in message_type_duplicates + ] + ) + ), + " ", + ), + file=sys.stderr, + ) + return 1 + + # Update database of known message specifications. + config.known_message_specifications.update({str(spec.base_type): spec for spec in message_specifications}) + + files_written: List[os.PathLike] = [] + messages_output_directory = args.output_directory / "msg" + if args.force_message_gen or not args.dry: + messages_output_directory.mkdir(exist_ok=True) + + # Write message specifications to .msg files. + for message_specification in message_specifications: + message_output_file = which_message_specification(message_specification, messages_output_directory) + if args.force_message_gen or not args.dry: + message_output_file.write_text(dump_message_specification(message_specification) + "\n") + files_written.append(message_output_file) + + # Write Python conversion .py file. + conversions_python_file = which_conversions_python_module(args.output_directory) + if not args.dry: + conversions_python_file.write_text(dump_conversions_python_module(message_specifications, config) + "\n") + files_written.append(conversions_python_file) + + if args.manifest_file: + # Write generation manifest file. + args.manifest_file.write_text("\n".join(map(str, files_written)) + "\n") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate Protobuf <-> ROS 2 interoperability interfaces") + parser.add_argument( + "-O", "--output-directory", type=pathlib.Path, default=".", help="Output directory for all generated files." + ) + parser.add_argument( + "-c", + "--config-file", + type=pathlib.Path, + default=importlib.resources.path("proto2ros.configuration", "default.yaml"), + help="Base configuration file for the generation procedure.", + ) + parser.add_argument( + "-a", + "--config-overlay-file", + dest="config_overlay_files", + type=pathlib.Path, + action="append", + default=[], + help="Optional configuration overlay files.", + ) + parser.add_argument( + "-m", + "--manifest-file", + type=pathlib.Path, + default=None, + help="Optional manifest file to track generated files.", + ) + parser.add_argument( + "-d", + "--dry", + action="store_true", + default=False, + help="Whether to perform a dry run or not (manifest is still written)", + ) + parser.add_argument( + "--force-message-gen", + action="store_true", + default=False, + help="Whether to generate message files regardless of other flags.", + ) + parser.add_argument("package_name", help="Name of the ROS package that will bear generated messages.") + parser.add_argument( + "descriptor_files", + type=pathlib.Path, + nargs="+", + help="Protobuf descriptor files to process, as generated by protoc.", + ) + args = parser.parse_args() + return do_generate(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/proto2ros/proto2ros/compatibility.py b/proto2ros/proto2ros/compatibility.py new file mode 100644 index 0000000..f14bca8 --- /dev/null +++ b/proto2ros/proto2ros/compatibility.py @@ -0,0 +1,10 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +# ruff: noqa +# fmt: off + +# NOTE(mhidalgo): handle https://bugs.launchpad.net/ubuntu/+source/networkx/+bug/2002660 +import numpy +numpy.int = numpy.int_ + +import networkx diff --git a/proto2ros/proto2ros/configuration/__init__.py b/proto2ros/proto2ros/configuration/__init__.py new file mode 100644 index 0000000..a552c99 --- /dev/null +++ b/proto2ros/proto2ros/configuration/__init__.py @@ -0,0 +1,94 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +"""This module defines configuration data structures for proto2ros generation.""" + +import dataclasses +import os +from typing import Any, Dict, Set, Union + +import yaml +from rosidl_adapter.parser import MessageSpecification + + +@dataclasses.dataclass +class Configuration: + """ + Mutable Protobuf <-> ROS configuration. + + Attributes: + + drop_deprecated: whether to drop deprecated fields on conversion or not. + If not dropped, deprecated fields are annotated with a comment. + passthrough_unknown: whether to forward Protobuf messages for which no + equivalent ROS message is known as a type erased, ``proto2ros/AnyProto`` + field or not. + message_mapping: a mapping from fully qualified Protobuf message type names + to fully qualified ROS message type names. This mapping comes first during + composite type translation. + package_mapping: a mapping from Protobuf package names to ROS package names, + to tell where a ROS equivalent for a Protobuf construct will be found. Note + that no checks for package existence are performed. This mapping comes + second during composite type translation (i.e. when direct message mapping + fails). + any_expansions: a mapping from fully qualified Protobuf field names (i.e. a + fully qualified Protobuf message type name followed by a dot "." followed by + the field name) of ``google.protobuf.Any`` type to Protobuf message type sets + that these fields are expected to pack. A single Protobuf message type may also + be specified in lieu of a single element set. All Protobuf message types must be + fully qualified. + allow_any_casts: when a single Protobuf message type is specified in an any expansion, + allowing any casts means to allow using the equivalent ROS message type instead of a + a type erased, ``proto2ros/Any`` field. + known_message_specifications: a mapping from ROS message type names to known message + specifications. Necessary to cascade message generation for interdependent packages. + python_imports: set of Python modules to be imported (as ``import ``) in + generated conversion modules. Typically, Protobuf and ROS message Python modules. + inline_python_imports: set of Python modules to be imported into moodule scope + (as ``from import *``) in generated conversion modules. Typically, + conversion Python modules. + skip_implicit_imports: whether to skip importing Python modules for Protobuf and ROS + packages known in generated conversion modules or not. + """ + + drop_deprecated: bool = False + passthrough_unknown: bool = True + package_mapping: Dict[str, str] = dataclasses.field(default_factory=dict) + message_mapping: Dict[str, str] = dataclasses.field(default_factory=dict) + + any_expansions: Dict[str, Union[Set[str], str]] = dataclasses.field(default_factory=dict) + allow_any_casts: bool = True + + known_message_specifications: Dict[str, MessageSpecification] = dataclasses.field(default_factory=dict) + + python_imports: Set[str] = dataclasses.field(default_factory=set) + inline_python_imports: Set[str] = dataclasses.field(default_factory=set) + skip_implicit_imports: bool = False + + def __post_init__(self) -> None: + """Enforces attribute types.""" + self.any_expansions = { + key: set(value) if not isinstance(value, str) else value for key, value in self.any_expansions.items() + } + self.python_imports = set(self.python_imports) + + def update(self, **attributes: Any) -> None: + """Updates configuration attributes with a shallow merge.""" + for name, value in attributes.items(): + old_value = getattr(self, name) + if hasattr(old_value, "update"): + old_value.update(value) + elif hasattr(old_value, "extend"): + old_value.extend(value) + else: + setattr(self, name, value) + + @classmethod + def updates_from_file(cls, path: os.PathLike) -> Dict[str, Any]: + """Reads configuration attribute updates from a file.""" + with open(path, "r") as f: + return yaml.safe_load(f) + + @classmethod + def from_file(cls, path: os.PathLike) -> "Configuration": + """Reads configuration from a file.""" + return cls(**cls.updates_from_file(path)) diff --git a/proto2ros/proto2ros/configuration/default.yaml b/proto2ros/proto2ros/configuration/default.yaml new file mode 100644 index 0000000..1fc770b --- /dev/null +++ b/proto2ros/proto2ros/configuration/default.yaml @@ -0,0 +1,27 @@ +message_mapping: + google.protobuf.Any: proto2ros/AnyProto + google.protobuf.Timestamp: builtin_interfaces/Time + google.protobuf.Duration: builtin_interfaces/Duration + google.protobuf.DoubleValue: std_msgs/Float64 + google.protobuf.FloatValue: std_msgs/Float32 + google.protobuf.Int64Value: std_msgs/Int64 + google.protobuf.UInt64Value: std_msgs/UInt64 + google.protobuf.Int32Value: std_msgs/Int32 + google.protobuf.UInt32Value: std_msgs/UInt32 + google.protobuf.BoolValue: std_msgs/Bool + google.protobuf.StringValue: std_msgs/String + google.protobuf.BytesValue: proto2ros/Bytes + google.protobuf.ListValue: proto2ros/List + google.protobuf.Value: proto2ros/Value + google.protobuf.Struct: proto2ros/Struct +python_imports: + - std_msgs.msg + - proto2ros.msg + - builtin_interfaces.msg + - google.protobuf.any_pb2 + - google.protobuf.duration_pb2 + - google.protobuf.struct_pb2 + - google.protobuf.timestamp_pb2 + - google.protobuf.wrappers_pb2 +inline_python_imports: + - proto2ros.conversions.basic diff --git a/proto2ros/proto2ros/conversions/__init__.py b/proto2ros/proto2ros/conversions/__init__.py new file mode 100644 index 0000000..a579f2f --- /dev/null +++ b/proto2ros/proto2ros/conversions/__init__.py @@ -0,0 +1,5 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. +import multipledispatch + +# Overloads entrypoint (via multiple dispatch) +convert = multipledispatch.Dispatcher("convert") diff --git a/proto2ros/proto2ros/conversions/basic.py b/proto2ros/proto2ros/conversions/basic.py new file mode 100644 index 0000000..90b8cd6 --- /dev/null +++ b/proto2ros/proto2ros/conversions/basic.py @@ -0,0 +1,447 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +"""This module provides basic conversion APIs, applicable to any proto2ros generated packages.""" + +from typing import Any + +import builtin_interfaces.msg +import google.protobuf.any_pb2 +import google.protobuf.duration_pb2 +import google.protobuf.struct_pb2 +import google.protobuf.timestamp_pb2 +import google.protobuf.wrappers_pb2 +import rclpy +import std_msgs.msg + +import proto2ros.msg +from proto2ros.conversions import convert + + +@convert.register(proto2ros.msg.AnyProto, object) +def convert_proto2ros_any_proto_message_to_some_proto(ros_msg: proto2ros.msg.AnyProto, proto_msg: Any) -> None: + """ + Unpacks a proto2ros/AnyProto ROS message into any Protobuf message. + + Raises: + ValueError: if the given ROS message cannot be unpacked onto the given Protobuf message. + """ + proto_msg.Clear() + wrapper = google.protobuf.Any() + wrapper.type_url = ros_msg.type_url + wrapper.value = ros_msg.value.tobytes() + if not wrapper.Unpack(proto_msg): + raise ValueError(f"failed to convert {ros_msg} to {proto_msg}") + + +@convert.register(object, proto2ros.msg.AnyProto) +def convert_some_proto_to_proto2ros_any_proto_message(proto_msg: Any, ros_msg: proto2ros.msg.AnyProto) -> None: + """Packs any Protobuf message into a proto2ros/AnyProto ROS message.""" + wrapper = google.protobuf.Any() + wrapper.Pack(proto_msg) + ros_msg.type_url = wrapper.type_url + ros_msg.value = wrapper.value + + +@convert.register(proto2ros.msg.AnyProto, google.protobuf.any_pb2.Any) +def convert_proto2ros_any_proto_message_to_google_protobuf_any_proto( + ros_msg: proto2ros.msg.AnyProto, proto_msg: google.protobuf.any_pb2.Any +) -> None: + """Converts from proto2ros/AnyProto ROS message to google.protobuf.Any Protobuf messages.""" + proto_msg.Clear() + proto_msg.type_url = ros_msg.type_url + proto_msg.value = ros_msg.value.tobytes() + + +@convert.register(google.protobuf.any_pb2.Any, proto2ros.msg.AnyProto) +def convert_google_protobuf_any_proto_to_proto2ros_any_proto_message( + proto_msg: google.protobuf.any_pb2.Any, ros_msg: proto2ros.msg.AnyProto +) -> None: + """Converts from google.protobuf.Any Protobuf messages to proto2ros/AnyProto ROS messages.""" + ros_msg.type_url = proto_msg.type_url + ros_msg.value = proto_msg.value + + +@convert.register(builtin_interfaces.msg.Duration, google.protobuf.duration_pb2.Duration) +def convert_builtin_interfaces_duration_message_to_google_protobuf_duration_proto( + ros_msg: builtin_interfaces.msg.Duration, proto_msg: google.protobuf.duration_pb2.Duration +) -> None: + """Converts from google.protobuf.Any Protobuf messages to proto2ros/AnyProto ROS messages.""" + proto_msg.seconds = ros_msg.sec + proto_msg.nanos = ros_msg.nanosec + + +convert_builtin_interfaces_duration_to_proto = ( + convert_builtin_interfaces_duration_message_to_google_protobuf_duration_proto +) + + +@convert.register(google.protobuf.duration_pb2.Duration, builtin_interfaces.msg.Duration) +def convert_google_protobuf_duration_proto_to_builtin_interfaces_duration_message( + proto_msg: google.protobuf.duration_pb2.Duration, ros_msg: builtin_interfaces.msg.Duration +) -> None: + """Converts from google.protobuf.Duration Protobuf messages to builtin_interfaces/Duration ROS messages.""" + ros_msg.sec = proto_msg.seconds + ros_msg.nanosec = proto_msg.nanos + + +convert_proto_to_builtin_interfaces_duration = ( + convert_google_protobuf_duration_proto_to_builtin_interfaces_duration_message +) + + +@convert.register(builtin_interfaces.msg.Time, google.protobuf.timestamp_pb2.Timestamp) +def convert_builtin_interfaces_time_message_to_google_protobuf_timestamp_proto( + ros_msg: builtin_interfaces.msg.Time, proto_msg: google.protobuf.timestamp_pb2.Timestamp +) -> None: + """Converts from builtin_interfaces/Time ROS messages to google.protobuf.Timestamp Protobuf messages.""" + proto_msg.seconds = ros_msg.sec + proto_msg.nanos = ros_msg.nanosec + + +convert_builtin_interfaces_time_to_proto = convert_builtin_interfaces_time_message_to_google_protobuf_timestamp_proto + + +@convert.register(google.protobuf.timestamp_pb2.Timestamp, builtin_interfaces.msg.Time) +def convert_google_protobuf_timestamp_proto_to_builtin_interfaces_time_message( + proto_msg: google.protobuf.timestamp_pb2.Timestamp, ros_msg: builtin_interfaces.msg.Time +) -> None: + """Converts from google.protobuf.Timestamp Protobuf messages to builtin_interfaces/Time ROS messages.""" + ros_msg.sec = proto_msg.seconds + ros_msg.nanosec = proto_msg.nanos + + +convert_proto_to_builtin_interfaces_time = convert_google_protobuf_timestamp_proto_to_builtin_interfaces_time_message + + +@convert.register(std_msgs.msg.Float64, google.protobuf.wrappers_pb2.DoubleValue) +def convert_std_msgs_float64_message_to_google_protobuf_double_value_proto( + ros_msg: std_msgs.msg.Float64, proto_msg: google.protobuf.wrappers_pb2.DoubleValue +) -> None: + """Converts from std_msgs/Float64 ROS messages to google.protobuf.DoubleValue Protobuf messages.""" + proto_msg.value = ros_msg.data + + +convert_std_msgs_float64_to_proto = convert_std_msgs_float64_message_to_google_protobuf_double_value_proto + + +@convert.register(google.protobuf.wrappers_pb2.DoubleValue, std_msgs.msg.Float64) +def convert_google_protobuf_double_value_proto_to_std_msgs_float64_message( + proto_msg: google.protobuf.wrappers_pb2.DoubleValue, ros_msg: std_msgs.msg.Float64 +) -> None: + """Converts from google.protobuf.DoubleValue Protobuf messages to std_msgs/Float64 ROS messages.""" + ros_msg.data = proto_msg.value + + +convert_proto_to_std_msgs_float64 = convert_google_protobuf_double_value_proto_to_std_msgs_float64_message + + +@convert.register(std_msgs.msg.Float32, google.protobuf.wrappers_pb2.FloatValue) +def convert_std_msgs_float32_message_to_google_protobuf_float_value_proto( + ros_msg: std_msgs.msg.Float32, proto_msg: google.protobuf.wrappers_pb2.FloatValue +) -> None: + """Converts from std_msgs/Float32 ROS messages to google.protobuf.FloatValue Protobuf messages.""" + proto_msg.value = ros_msg.data + + +convert_std_msgs_float32_to_proto = convert_std_msgs_float32_message_to_google_protobuf_float_value_proto + + +@convert.register(google.protobuf.wrappers_pb2.FloatValue, std_msgs.msg.Float32) +def convert_google_protobuf_float_value_proto_to_std_msgs_float32_message( + proto_msg: google.protobuf.wrappers_pb2.FloatValue, ros_msg: std_msgs.msg.Float32 +) -> None: + """Converts from google.protobuf.FloatValue Protobuf messages to std_msgs/Float32 ROS messages.""" + ros_msg.data = proto_msg.value + + +convert_proto_to_std_msgs_float32 = convert_google_protobuf_float_value_proto_to_std_msgs_float32_message + + +@convert.register(std_msgs.msg.Int64, google.protobuf.wrappers_pb2.Int64Value) +def convert_std_msgs_int64_message_to_google_protobuf_int64_value_proto( + ros_msg: std_msgs.msg.Int64, proto_msg: google.protobuf.wrappers_pb2.Int64Value +) -> None: + """Converts from std_msgs/Int64 ROS messages to google.protobuf.Int64Value Protobuf messages.""" + proto_msg.value = ros_msg.data + + +convert_std_msgs_int64_to_proto = convert_std_msgs_int64_message_to_google_protobuf_int64_value_proto + + +@convert.register(google.protobuf.wrappers_pb2.Int64Value, std_msgs.msg.Int64) +def convert_google_protobuf_int64_value_proto_to_std_msgs_int64_message( + proto_msg: google.protobuf.wrappers_pb2.Int64Value, ros_msg: std_msgs.msg.Int64 +) -> None: + """Converts from google.protobuf.Int64Value Protobuf messages to std_msgs/Int64 ROS messages.""" + ros_msg.data = proto_msg.value + + +convert_proto_to_std_msgs_int64 = convert_google_protobuf_int64_value_proto_to_std_msgs_int64_message + + +@convert.register(std_msgs.msg.Int32, google.protobuf.wrappers_pb2.Int32Value) +def convert_std_msgs_int32_message_to_google_protobuf_int32_value_proto( + ros_msg: std_msgs.msg.Int32, proto_msg: google.protobuf.wrappers_pb2.Int32Value +) -> None: + """Converts from std_msgs/Int32 ROS messages to google.protobuf.Int32Value Protobuf messages.""" + proto_msg.value = ros_msg.data + + +convert_std_msgs_int32_to_proto = convert_std_msgs_int32_message_to_google_protobuf_int32_value_proto + + +@convert.register(google.protobuf.wrappers_pb2.Int32Value, std_msgs.msg.Int32) +def convert_google_protobuf_int32_value_proto_to_std_msgs_int32_message( + proto_msg: google.protobuf.wrappers_pb2.Int32Value, ros_msg: std_msgs.msg.Int32 +) -> None: + """Converts from google.protobuf.Int32Value Protobuf messages to std_msgs/Int32 ROS messages.""" + ros_msg.data = proto_msg.value + + +convert_proto_to_std_msgs_int32 = convert_google_protobuf_int32_value_proto_to_std_msgs_int32_message + + +@convert.register(std_msgs.msg.UInt64, google.protobuf.wrappers_pb2.UInt64Value) +def convert_std_msgs_uint64_message_to_google_protobuf_uint64_value_proto( + ros_msg: std_msgs.msg.UInt64, proto_msg: google.protobuf.wrappers_pb2.UInt64Value +) -> None: + """Converts from std_msgs/UInt64 ROS messages to google.protobuf.UInt64Value Protobuf messages.""" + proto_msg.value = ros_msg.data + + +convert_std_msgs_uint64_to_proto = convert_std_msgs_uint64_message_to_google_protobuf_uint64_value_proto + + +@convert.register(google.protobuf.wrappers_pb2.UInt64Value, std_msgs.msg.UInt64) +def convert_google_protobuf_uint64_value_proto_to_std_msgs_uint64_message( + proto_msg: google.protobuf.wrappers_pb2.UInt64Value, ros_msg: std_msgs.msg.UInt64 +) -> None: + """Converts from google.protobuf.UInt64Value Protobuf messages to std_msgs/UInt64 ROS messages.""" + ros_msg.data = proto_msg.value + + +convert_proto_to_std_msgs_uint64 = convert_google_protobuf_uint64_value_proto_to_std_msgs_uint64_message + + +@convert.register(std_msgs.msg.UInt32, google.protobuf.wrappers_pb2.UInt32Value) +def convert_std_msgs_uint32_message_to_google_protobuf_uint32_value_proto( + ros_msg: std_msgs.msg.UInt32, proto_msg: google.protobuf.wrappers_pb2.UInt32Value +) -> None: + """Converts from std_msgs/UInt32 ROS messages to google.protobuf.UInt32Value Protobuf messages.""" + proto_msg.value = ros_msg.data + + +convert_std_msgs_uint32_to_proto = convert_std_msgs_uint32_message_to_google_protobuf_uint32_value_proto + + +@convert.register(google.protobuf.wrappers_pb2.UInt32Value, std_msgs.msg.UInt32) +def convert_google_protobuf_uint32_value_proto_to_std_msgs_uint32_message( + proto_msg: google.protobuf.wrappers_pb2.UInt32Value, ros_msg: std_msgs.msg.UInt32 +) -> None: + """Converts from google.protobuf.UInt32Value Protobuf messages to std_msgs/UInt32 ROS messages.""" + ros_msg.data = proto_msg.value + + +convert_proto_to_std_msgs_uint32 = convert_google_protobuf_uint32_value_proto_to_std_msgs_uint32_message + + +@convert.register(std_msgs.msg.Bool, google.protobuf.wrappers_pb2.BoolValue) +def convert_std_msgs_bool_message_to_google_protobuf_bool_value_proto( + ros_msg: std_msgs.msg.Bool, proto_msg: google.protobuf.wrappers_pb2.BoolValue +) -> None: + """Converts from std_msgs/Bool ROS messages to google.protobuf.BoolValue Protobuf messages.""" + proto_msg.value = ros_msg.data + + +convert_std_msgs_bool_to_proto = convert_std_msgs_bool_message_to_google_protobuf_bool_value_proto + + +@convert.register(google.protobuf.wrappers_pb2.BoolValue, std_msgs.msg.Bool) +def convert_google_protobuf_bool_value_proto_to_std_msgs_bool_message( + proto_msg: google.protobuf.wrappers_pb2.BoolValue, ros_msg: std_msgs.msg.Bool +) -> None: + """Converts from google.protobuf.BoolValue Protobuf messages to std_msgs/Bool ROS messages.""" + ros_msg.data = proto_msg.value + + +convert_proto_to_std_msgs_bool = convert_google_protobuf_bool_value_proto_to_std_msgs_bool_message + + +@convert.register(std_msgs.msg.String, google.protobuf.wrappers_pb2.StringValue) +def convert_std_msgs_string_message_to_google_protobuf_string_value_proto( + ros_msg: std_msgs.msg.String, proto_msg: google.protobuf.wrappers_pb2.StringValue +) -> None: + """Converts from std_msgs/String ROS messages to google.protobuf.StringValue Protobuf messages.""" + proto_msg.value = ros_msg.data + + +convert_std_msgs_string_to_proto = convert_std_msgs_string_message_to_google_protobuf_string_value_proto + + +@convert.register(google.protobuf.wrappers_pb2.StringValue, std_msgs.msg.String) +def convert_google_protobuf_string_value_proto_to_std_msgs_string_message( + proto_msg: google.protobuf.wrappers_pb2.StringValue, ros_msg: std_msgs.msg.String +) -> None: + """Converts from google.protobuf.StringValue Protobuf messages to std_msgs/String ROS messages.""" + ros_msg.data = proto_msg.value + + +convert_proto_to_std_msgs_string = convert_google_protobuf_string_value_proto_to_std_msgs_string_message + + +@convert.register(proto2ros.msg.Bytes, google.protobuf.wrappers_pb2.BytesValue) +def convert_proto2ros_bytes_message_to_google_protobuf_bytes_value_proto( + ros_msg: proto2ros.msg.Bytes, proto_msg: google.protobuf.wrappers_pb2.BytesValue +) -> None: + """Converts from proto2ros/Bytes ROS messages to google.protobuf.BytesValue Protobuf messages.""" + proto_msg.value = ros_msg.data.tobytes() + + +convert_proto2ros_bytes_to_proto = convert_proto2ros_bytes_message_to_google_protobuf_bytes_value_proto + + +@convert.register(google.protobuf.wrappers_pb2.BytesValue, proto2ros.msg.Bytes) +def convert_google_protobuf_bytes_value_proto_to_proto2ros_bytes_message( + proto_msg: google.protobuf.wrappers_pb2.BytesValue, ros_msg: proto2ros.msg.Bytes +) -> None: + """Converts from google.protobuf.BytesValue Protobuf messages to proto2ros/Bytes ROS messages.""" + ros_msg.data = proto_msg.value + + +convert_proto_to_proto2ros_bytes = convert_google_protobuf_bytes_value_proto_to_proto2ros_bytes_message + + +@convert.register(proto2ros.msg.Value, google.protobuf.struct_pb2.Value) +def convert_proto2ros_value_message_to_google_protobuf_value_proto( + ros_msg: proto2ros.msg.Value, proto_msg: google.protobuf.struct_pb2.Value +) -> None: + """Converts from proto2ros/Value ROS messages to google.protobuf.Value Protobuf messages.""" + match ros_msg.kind: + case proto2ros.msg.Value.NUMBER_VALUE_SET: + proto_msg.number_value = ros_msg.number_value + case proto2ros.msg.Value.STRING_VALUE_SET: + proto_msg.string_value = ros_msg.string_value + case proto2ros.msg.Value.BOOL_VALUE_SET: + proto_msg.bool_value = ros_msg.bool_value + case proto2ros.msg.Value.STRUCT_VALUE_SET: + if proto_msg.struct_value.type_name != "proto2ros/Struct": + raise ValueError( + f"expected proto2ros/Struct message for struct_value member, got {proto_msg.struct_value.type}" + ) + typed_field_message = rclpy.serialization.deserialize_message( + proto_msg.struct_value.value.tobytes(), proto2ros.msg.Struct + ) + convert_proto2ros_struct_message_to_google_protobuf_struct_proto( + typed_field_message, proto_msg.struct_value + ) + case proto2ros.msg.Value.LIST_VALUE_SET: + if proto_msg.list_value.type_name != "proto2ros/List": + raise ValueError( + f"expected proto2ros/Struct message for list_value member, got {proto_msg.list_value.type}" + ) + typed_field_message = rclpy.serialization.deserialize_message( + proto_msg.list_value.value.tobytes(), proto2ros.msg.List + ) + convert_proto2ros_list_message_to_google_protobuf_list_value_proto( + typed_field_message, proto_msg.list_value + ) + case proto2ros.msg.Value.NO_VALUE_SET: + proto_msg.null_value = google.protobuf.struct_pb2.NullValue.NULL_VALUE + case _: + raise ValueError(f"unexpected value in kind member: {ros_msg.kind}") + + +convert_proto2ros_value_to_proto = convert_proto2ros_value_message_to_google_protobuf_value_proto + + +@convert.register(google.protobuf.struct_pb2.Value, proto2ros.msg.Value) +def convert_google_protobuf_value_proto_to_proto2ros_value_message( + proto_msg: google.protobuf.struct_pb2.Value, ros_msg: proto2ros.msg.Value +) -> None: + """Converts from google.protobuf.Value Protobuf messages to proto2ros/Value ROS messages.""" + match proto_msg.WhichOneOf("kind"): + case "null_value": + ros_msg.kind = proto2ros.msg.Value.NO_VALUE_SET + case "number_value": + ros_msg.number_value = proto_msg.number_value + ros_msg.kind = proto2ros.msg.Value.NUMBER_VALUE_SET + case "string_value": + ros_msg.string_value = proto_msg.string_value + ros_msg.kind = proto2ros.msg.Value.STRING_VALUE_SET + case "bool_value": + ros_msg.bool_value = proto_msg.bool_value + ros_msg.kind = proto2ros.msg.Value.BOOL_VALUE_SET + case "struct_value": + typed_struct_message = proto2ros.msg.Struct() + convert_google_protobuf_struct_proto_to_proto2ros_struct_message( + proto_msg.struct_value, typed_struct_message + ) + ros_msg.struct_value.value = rclpy.serialization.serialize_message(typed_struct_message) + ros_msg.struct_value.type_name = "proto2ros/Struct" + ros_msg.kind = proto2ros.msg.Value.STRUCT_VALUE_SET + case "list_value": + typed_list_message = proto2ros.msg.List() + convert_google_protobuf_list_value_proto_to_proto2ros_list_message(proto_msg.list_value, typed_list_message) + ros_msg.list_value.value = rclpy.serialization.serialize_message(typed_list_message) + ros_msg.list_value.type_name = "proto2ros/List" + ros_msg.kind = proto2ros.msg.Value.LIST_VALUE_SET + case _: + raise ValueError("unexpected one-of field: " + proto_msg.WhichOneOf("kind")) + + +convert_proto_to_proto2ros_value = convert_google_protobuf_value_proto_to_proto2ros_value_message + + +@convert.register(proto2ros.msg.List, google.protobuf.struct_pb2.ListValue) +def convert_proto2ros_list_message_to_google_protobuf_list_value_proto( + ros_msg: proto2ros.msg.List, proto_msg: google.protobuf.struct_pb2.ListValue +) -> None: + """Converts from proto2ros/List ROS messages to google.protobuf.ListValue Protobuf messages.""" + proto_msg.Clear() + for input_item in ros_msg.values: + output_item = proto_msg.values.add() + convert_proto2ros_value_message_to_google_protobuf_value_proto(input_item, output_item) + + +convert_proto2ros_list_to_proto = convert_proto2ros_list_message_to_google_protobuf_list_value_proto + + +@convert.register(google.protobuf.struct_pb2.ListValue, proto2ros.msg.List) +def convert_google_protobuf_list_value_proto_to_proto2ros_list_message( + proto_msg: google.protobuf.struct_pb2.ListValue, ros_msg: proto2ros.msg.List +) -> None: + """Converts from google.protobuf.ListValue Protobuf messages to proto2ros/List ROS messages.""" + for input_item in proto_msg.values: + output_item = proto2ros.msg.Value() + convert_google_protobuf_value_proto_to_proto2ros_value_message(input_item, output_item) + ros_msg.values.append(output_item) + + +convert_proto_to_proto2ros_list = convert_google_protobuf_list_value_proto_to_proto2ros_list_message + + +@convert.register(proto2ros.msg.Struct, google.protobuf.struct_pb2.Struct) +def convert_proto2ros_struct_message_to_google_protobuf_struct_proto( + ros_msg: proto2ros.msg.Struct, proto_msg: google.protobuf.struct_pb2.Struct +) -> None: + """Converts from proto2ros/Struct ROS messages to google.protobuf.Struct Protobuf messages.""" + proto_msg.Clear() + for field in ros_msg.fields: + proto_msg.fields[field.key].CopyFrom(field.value) + + +convert_proto2ros_struct_to_proto = convert_proto2ros_struct_message_to_google_protobuf_struct_proto + + +@convert.register(google.protobuf.struct_pb2.Struct, proto2ros.msg.Struct) +def convert_google_protobuf_struct_proto_to_proto2ros_struct_message( + proto_msg: google.protobuf.struct_pb2.Struct, ros_msg: proto2ros.msg.Struct +) -> None: + """Converts from google.protobuf.Struct Protobuf messages to proto2ros/Struct ROS messages.""" + for key, value in proto_msg.fields.items(): + field = proto2ros.msg.StructEntry(key=key) + convert_google_protobuf_value_proto_to_proto2ros_value_message(value, field.value) + ros_msg.fields.append(field) + + +convert_proto_to_proto2ros_struct = convert_google_protobuf_struct_proto_to_proto2ros_struct_message diff --git a/proto2ros/proto2ros/dependencies.py b/proto2ros/proto2ros/dependencies.py new file mode 100644 index 0000000..a52cf7f --- /dev/null +++ b/proto2ros/proto2ros/dependencies.py @@ -0,0 +1,63 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +""" +This module provides APIs to manipulate dependencies between Protobuf <-> ROS message equivalences. +""" + +import warnings +from typing import List + +from rosidl_adapter.parser import MessageSpecification + +from proto2ros.compatibility import networkx as nx +from proto2ros.utilities import pairwise, to_ros_base_type + + +def message_dependency_graph(message_specs: List[MessageSpecification]) -> nx.DiGraph: + """ + Returns the dependency graph for the given ROS message specifications. + + This dependency graph is a directed multi-graph where message types make up nodes + and composition relationships (has-a) make up edges. Nodes are annotated with the + corresponding message specification, while edges are annotated with the corresponding + field specification. + """ + dependency_graph = nx.MultiDiGraph() + for message in message_specs: + dependency_graph.add_node(str(message.base_type), message=message) + for field in message.fields: + if field.type.is_primitive_type(): + continue + dependency_graph.add_edge(str(message.base_type), to_ros_base_type(field.type), field=field) + return dependency_graph + + +def fix_dependency_cycles(message_specs: List[MessageSpecification], quiet: bool = True) -> None: + """ + Fixes dependency cycles among ROS message specifications. + + ROS messages do not support recursive definitions, this functions works around this + limitation by type erasing the thinnest link (least number of offending fields) for + each cycle. + """ + dependency_graph = message_dependency_graph(message_specs) + for cycle in nx.simple_cycles(dependency_graph): + cycle = [*cycle, cycle[0]] # close the loop + if not quiet: + message_types = [dependency_graph.nodes[node]["message"].base_type for node in cycle] + dependency_cycle_depiction = " -> ".join(str(type_) for type_ in message_types) + warnings.warn("Dependency cycle found: " + dependency_cycle_depiction) + + explicit_edges = [] + for parent, child in pairwise(cycle): + message = dependency_graph.nodes[child]["message"] + if message.annotations["proto-class"] == "message": + explicit_edges.append((parent, child)) + + parent, child = min(explicit_edges, key=lambda edge: dependency_graph.number_of_edges(*edge)) + for data in dependency_graph[parent][child].values(): + field = data["field"] + if not quiet: + message_type = dependency_graph.nodes[parent]["message"].base_type + warnings.warn(f"Type erasing {field.name} member in {message_type} to break recursion") + field.annotations["type-erased"] = True diff --git a/proto2ros/proto2ros/descriptors/__init__.py b/proto2ros/proto2ros/descriptors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/proto2ros/proto2ros/descriptors/sources.py b/proto2ros/proto2ros/descriptors/sources.py new file mode 100644 index 0000000..8631b41 --- /dev/null +++ b/proto2ros/proto2ros/descriptors/sources.py @@ -0,0 +1,56 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +"""This module provides APIs to work with Protobuf definition sources.""" + +import functools +import os +from typing import Any, Iterable + +from google.protobuf.descriptor_pb2 import FileDescriptorProto, FileDescriptorSet + + +@functools.singledispatch +def read_source_descriptors(source: Any) -> Iterable[FileDescriptorProto]: + """ + Reads Protobuf file source descriptors. + + Note this function operates as the entrypoint to all corresponding overloads (via single dispatch). + + Args: + source: a source to be read for descriptors. + + Returns: + an iterable over all file source descriptors found. + """ + raise NotImplementedError(f"not implemented for {source}") + + +@read_source_descriptors.register +def read_source_descriptors_from_bytes(blob: bytes) -> Iterable[FileDescriptorProto]: + """ + Reads Protobuf file source descriptors from a binary blob. + + Args: + source: a binary blob, typically read from a .desc file. + + Returns: + an iterable over all file source descriptors found. + """ + descriptor = FileDescriptorSet() + descriptor.ParseFromString(blob) + yield from descriptor.file + + +@read_source_descriptors.register +def read_source_descriptors_from_file(path: os.PathLike) -> Iterable[FileDescriptorProto]: + """ + Reads Protobuf file source descriptors from binary file. + + Args: + path: path to binary file, typically a .desc file. + + Returns: + an iterable over all file source descriptors found. + """ + with open(path, "rb") as f: + yield from read_source_descriptors_from_bytes(f.read()) diff --git a/proto2ros/proto2ros/descriptors/types.py b/proto2ros/proto2ros/descriptors/types.py new file mode 100644 index 0000000..2696c2a --- /dev/null +++ b/proto2ros/proto2ros/descriptors/types.py @@ -0,0 +1,25 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +"""This module provides Protobuf typing details.""" + +from google.protobuf.descriptor_pb2 import FieldDescriptorProto + +COMPOSITE_TYPES = {FieldDescriptorProto.TYPE_MESSAGE, FieldDescriptorProto.TYPE_ENUM} + +PRIMITIVE_TYPE_NAMES = { + FieldDescriptorProto.TYPE_BOOL: "bool", + FieldDescriptorProto.TYPE_DOUBLE: "double", + FieldDescriptorProto.TYPE_FIXED32: "fixed32", + FieldDescriptorProto.TYPE_FIXED64: "fixed64", + FieldDescriptorProto.TYPE_FLOAT: "float", + FieldDescriptorProto.TYPE_INT32: "int32", + FieldDescriptorProto.TYPE_INT64: "int64", + FieldDescriptorProto.TYPE_SFIXED32: "sfixed32", + FieldDescriptorProto.TYPE_SFIXED64: "sfixed64", + FieldDescriptorProto.TYPE_SINT32: "sint32", + FieldDescriptorProto.TYPE_SINT64: "sint64", + FieldDescriptorProto.TYPE_UINT32: "uint32", + FieldDescriptorProto.TYPE_UINT64: "uint64", + FieldDescriptorProto.TYPE_STRING: "string", + FieldDescriptorProto.TYPE_BYTES: "bytes", +} diff --git a/proto2ros/proto2ros/descriptors/utilities.py b/proto2ros/proto2ros/descriptors/utilities.py new file mode 100644 index 0000000..06ca6f9 --- /dev/null +++ b/proto2ros/proto2ros/descriptors/utilities.py @@ -0,0 +1,112 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +""" +This module provides utilities to work with Protobuf descriptors. + +Many of these utilities trade in terms of paths and locations. + +Paths are sequences of numbers that refer to an arbitrarily nested value in a Protobuf message. +Each part of a path is one or two numbers: the field number if it is not a repeated field, and +the field number and item index if it is. For example, ``[4, 1, 3]`` can be (but it ultimately +depends on the concrete Protobuf message type) a path to the third item of field number 1 of +the (sub)message that is field number 4 of the message this path applies to. This path can thus +be used to access the target value: take field number 4 of the message, it is a message itself; +then take field number 1 of this (sub)message, it is a repeated field; then take item 3. + +Locations refer to portions of a .proto source file. Locations specify a path to the descriptors +of the constructs that are defined in the corresponding portion. See +[``SourceCodeInfo``](https://github.com/protocolbuffers/protobuf/blob/main/benchmarks/descriptor.proto#L710) +message description for further reference. +""" + +import itertools +from collections.abc import Sequence +from typing import Any, Dict, Iterable, Optional, Tuple + +from google.protobuf.descriptor_pb2 import FieldDescriptorProto, FileDescriptorProto, SourceCodeInfo + +from proto2ros.utilities import identity_lru_cache + + +@identity_lru_cache() +def index_source_code_locations(file_descriptor: FileDescriptorProto) -> Dict[Tuple[int, ...], SourceCodeInfo.Location]: + """Indexes all source code locations in a source file descriptor by path.""" + info = file_descriptor.source_code_info + return {tuple(location.path): location for location in info.location} + + +def walk(proto: Any, path: Sequence[int]) -> Iterable[Any]: + """ + Iterates a Protobuf message down a given path. + + Args: + proto: a Protobuf message instance to visit. + path: path to iterate along. + + Returns: + an iterable over Protobuf message members. + """ + field_descriptor, field_value = next(item for item in proto.ListFields() if item[0].number == path[0]) + if field_descriptor.label == field_descriptor.LABEL_REPEATED: + field_value = field_value[path[1]] + path = path[1:] + yield field_value + if len(path) > 1: + yield from walk(field_value, path[1:]) + + +def locate_repeated(member: str, proto: Any) -> Iterable[Tuple[Sequence[int], Any]]: + """ + Iterates over items of a repeated Protobuf message member, also yield their local paths. + + Local paths are tuples of member field number and item index. + + Args: + member: name of the repeated message member field. + proto: Protobuf message instance to access. + + Returns: + an iterable over tuples of local path and member field value. + """ + if member not in proto.DESCRIPTOR.fields_by_name: + raise ValueError(f"{member} is not a member of the given protobuf") + member_field_descriptor = proto.DESCRIPTOR.fields_by_name[member] + if member_field_descriptor.label != FieldDescriptorProto.LABEL_REPEATED: + raise ValueError(f"{member} is not a repeated member of the given protobuf") + for i, member_item in enumerate(getattr(proto, member)): + yield (member_field_descriptor.number, i), member_item + + +def resolve( + source: FileDescriptorProto, path: Iterable[int], root: Optional[SourceCodeInfo.Location] = None +) -> SourceCodeInfo.Location: + """ + Resolves a source path to a location. + + Args: + source: source file descriptor. + path: source path to be resolved. + root: optional root location to resolve against. + + Returns: + resolved location. + """ + locations = index_source_code_locations(source) + if root is not None: + path = itertools.chain(root.path, path) + path = tuple(path) + if path not in locations: + location = SourceCodeInfo.Location() + location.path.extend(path) + return location + return locations[path] + + +def protofqn(source: FileDescriptorProto, location: SourceCodeInfo.Location) -> str: + """ + Returns the fully qualified name of a Protobuf composite type. + + This type is to be found at a given `location` in a given `source` file. + """ + name = ".".join(proto.name for proto in walk(source, location.path)) + return f"{source.package}.{name}" diff --git a/proto2ros/proto2ros/equivalences.py b/proto2ros/proto2ros/equivalences.py new file mode 100644 index 0000000..a89a21c --- /dev/null +++ b/proto2ros/proto2ros/equivalences.py @@ -0,0 +1,480 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +""" +This module provides APIs to extract Protobuf <-> ROS message equivalences. + +These equivalences are defined in terms of Protobuf composite descriptors +and ROS message specifications. See Protobuf descriptor messages and ROS 2 +``MessageSpecification`` class definition and documentation in +https://github.com/protocolbuffers/protobuf/blob/main/src/google/protobuf/descriptor.proto +and https://github.com/ros2/rosidl/blob/rolling/rosidl_adapter/rosidl_adapter/parser.py +respectively for further reference. +""" + +import dataclasses +import functools +import math +from collections.abc import Sequence +from typing import Any, Iterable, List, Optional, Union + +import inflection +from google.protobuf.descriptor_pb2 import ( + DescriptorProto, + EnumDescriptorProto, + FieldDescriptorProto, + FileDescriptorProto, + SourceCodeInfo, +) +from rosidl_adapter.parser import Constant, Field, MessageSpecification, Type + +from proto2ros.configuration import Configuration +from proto2ros.descriptors.types import COMPOSITE_TYPES, PRIMITIVE_TYPE_NAMES +from proto2ros.descriptors.utilities import locate_repeated, protofqn, resolve, walk +from proto2ros.utilities import to_ros_base_type, to_ros_field_name + + +@dataclasses.dataclass +class Equivalence: + """ + An equivalence relation between a Protobuf composite (message or enum) and a ROS message. + + More than one ROS message specification may be required to represent a single Protobuf + composite. Auxiliary message specifications may be listed in that case. + + Attributes: + proto_spec: a Protobuf composite type descriptor (either a message or an enum). + message_spec: an equivalent ROS message specification. + auxiliary_message_specs: additional ROS message specifications necessary to define + an equivalence (e.g. one-of equivalents, enum equivalents, etc.). + """ + + proto_spec: Union[DescriptorProto, EnumDescriptorProto] + message_spec: MessageSpecification + auxiliary_message_specs: Optional[Sequence[MessageSpecification]] = None + + +def all_message_specifications(eqs: List[Equivalence]) -> Iterable[MessageSpecification]: + """Yields all message specifications to be found in the given equivalence relations.""" + for eq in eqs: + yield eq.message_spec + if eq.auxiliary_message_specs: + yield from eq.auxiliary_message_specs + + +def equivalent_ros_name(source: FileDescriptorProto, location: SourceCodeInfo.Location) -> str: + """Returns an equivalent ROS message name for a Protobuf composite type in a given location.""" + return "".join(inflection.camelize(proto.name) for proto in walk(source, location.path)) + + +def extract_leading_comments(location: SourceCodeInfo.Location) -> str: + """Returns leading comments for construct at the given location.""" + comments = [*location.leading_detached_comments, location.leading_comments] + # Remove backslashes as it causes issues further down the line within rosidl. + comments = [comment.strip("\n").replace("\\", "") for comment in comments] + return "\n\n".join(comment for comment in comments if comment) + + +@functools.singledispatch +def compute_equivalence( + descriptor: Any, source: FileDescriptorProto, location: SourceCodeInfo.Location, config: Configuration +) -> Equivalence: + """ + Computes a suitable equivalence relation for some Protobuf composite type. + + Note this function operates as the entrypoint to all corresponding overloads (via single dispatch). + + Args: + descriptor: the descriptor of the Protobuf composite type of interest. + source: the descriptor of the Protobuf source file where the Protobuf composite type is defined. + location: the location of the Protobuf composite type in the aforementioned source file. + config: a suitable configuration for the procedure. + + Returns: + an equivalence relation. + """ + raise NotImplementedError(f"not implemented for {descriptor}") + + +@compute_equivalence.register +def compute_equivalence_for_enum( + descriptor: EnumDescriptorProto, + source: FileDescriptorProto, + location: SourceCodeInfo.Location, + config: Configuration, +) -> Equivalence: + """ + Computes a suitable equivalence relation for a Protobuf enum type. + + Args: + descriptor: the descriptor of the Protobuf enum of interest. + source: the descriptor of the Protobuf source file where the Protobuf enum type is defined. + location: the location of the Protobuf enum type in the aforementioned source file. + config: a suitable configuration for the procedure. + + Returns: + an equivalence relation. + """ + constants: List[Constant] = [] + for value_path, value_descriptor in locate_repeated("value", descriptor): + constant = Constant("int32", value_descriptor.name, value_descriptor.number) + value_location = resolve(source, value_path, location) + leading_comments = extract_leading_comments(value_location) + if leading_comments: + constant.annotations["comment"] = leading_comments + constants.append(constant) + fields = [Field(Type("int32"), "value")] + fields[-1].annotations["optional"] = False + package_name = config.package_mapping[source.package] + name = equivalent_ros_name(source, location) + message_spec = MessageSpecification(package_name, name, fields, constants) + leading_comments = extract_leading_comments(location) + if leading_comments: + message_spec.annotations["comment"] = leading_comments + message_spec.annotations["proto-type"] = protofqn(source, location) + message_spec.annotations["proto-class"] = "enum" + return Equivalence(proto_spec=descriptor, message_spec=message_spec) + + +def translate_type_name(name: str, config: Configuration) -> str: + """ + Translates a Protobuf type name to its ROS equivalent. + + Args: + name: fully qualified Protobuf type name. + config: a suitable configuration for the procedure. + + Returns: + an fully qualified ROS message type name. + + Raises: + ValueError: when `name` is not fully qualified or + when it cannot be resolved to a ROS message type name. + """ + if not name.startswith("."): + raise ValueError(f"'{name}' is not a fully qualified type name") + proto_type_name = name[1:] + + if proto_type_name == "google.protobuf.Any": + return "proto2ros/Any" + + if proto_type_name in config.message_mapping: + return config.message_mapping[proto_type_name] + + matching_proto_packages = [ + proto_package for proto_package in config.package_mapping if proto_type_name.startswith(proto_package + ".") + ] + if matching_proto_packages: + proto_package = max(matching_proto_packages, key=len) + ros_package = config.package_mapping[proto_package] + proto_type_name = proto_type_name.removeprefix(proto_package + ".") + ros_type_name = inflection.camelize(proto_type_name.replace(".", "_")) + return f"{ros_package}/{ros_type_name}" + + if not config.passthrough_unknown: + raise ValueError(f"cannot resolve '{name}' type name") + return "proto2ros/AnyProto" + + +PRIMITIVE_TYPES_MAPPING = { + "bool": "bool", + "double": "float64", + "fixed32": "uint32", + "fixed64": "uint64", + "float": "float32", + "int32": "int32", + "int64": "int64", + "sfixed32": "int32", + "sfixed64": "int64", + "sint32": "int32", + "sint64": "int64", + "uint32": "uint32", + "uint64": "uint64", + "string": "string", +} + + +def translate_type(name: str, repeated: bool, config: Configuration) -> Type: + """ + Translates a Protobuf type to its ROS equivalent. + + Args: + name: Protobuf type name. + repeated: whether the Protobuf type applies to a repeated field. + config: a suitable configuration for the procedure. + + Returns: + a ROS message type. + """ + if name != "bytes": + if name not in PRIMITIVE_TYPES_MAPPING: + ros_type_name = translate_type_name(name, config) + else: + ros_type_name = PRIMITIVE_TYPES_MAPPING[name] + if repeated: + ros_type_name += "[]" + else: + ros_type_name = "proto2ros/Bytes[]" if repeated else "uint8[]" + return Type(ros_type_name) + + +def translate_field( + descriptor: FieldDescriptorProto, + source: FileDescriptorProto, + location: SourceCodeInfo.Location, + config: Configuration, +) -> Field: + """ + Translates a Protobuf field descriptor to its ROS equivalent. + + Args: + descriptor: a Protobuf field descriptor. + source: the descriptor of the Protobuf source file where the Protobuf field is defined. + location: the location of the Protobuf field in the aforementioned source file. + config: a suitable configuration for the procedure. + + Returns: + an fully qualified ROS message type name. + + Raises: + ValueError: when the given field is of an unsupported or unknown type. + ValueError: when an any expansion is specified for a fully typed field. + """ + any_expansion = config.any_expansions.get(protofqn(source, location)) + if any_expansion and descriptor.type_name != ".google.protobuf.Any": + raise ValueError(f"any expansion specified for '{descriptor.name}' field of {descriptor.type_name} type") + if descriptor.type in PRIMITIVE_TYPE_NAMES: + type_name = PRIMITIVE_TYPE_NAMES[descriptor.type] + elif descriptor.type in COMPOSITE_TYPES: + if any_expansion: + if config.allow_any_casts and isinstance(any_expansion, str): + # Type name is expected to be fully qualified, thus the leading dot. See + # https://github.com/protocolbuffers/protobuf/blob/main/src/google/protobuf/descriptor.proto#L265-L270 + # for further reference. + type_name = f".{any_expansion}" + else: + type_name = descriptor.type_name + else: + type_name = descriptor.type_name + else: + raise ValueError(f"unsupported field type: {descriptor.type}") + repeated = descriptor.label == FieldDescriptorProto.LABEL_REPEATED + + field_type = translate_type(type_name, repeated, config) + field = Field(field_type, to_ros_field_name(descriptor.name)) + if any_expansion: + if not config.allow_any_casts or not isinstance(any_expansion, str): + # Annotate field with paired Protobuf and ROS message type names, + # so that any expansions can be resolved in conversion code. + field.annotations["type-casts"] = [ + (proto_type, translate_type_name(proto_type, config)) for proto_type in any_expansion + ] + else: + field.annotations["type-casted"] = True + if source.syntax == "proto3": + field.annotations["optional"] = descriptor.proto3_optional or ( + descriptor.label != FieldDescriptorProto.LABEL_REPEATED + and descriptor.type == FieldDescriptorProto.TYPE_MESSAGE + ) + elif source.syntax == "proto2": + field.annotations["optional"] = descriptor.label != FieldDescriptorProto.LABEL_REPEATED + else: + raise ValueError(f"unknown proto syntax: {source.syntax}") + field.annotations["proto-name"] = descriptor.name + if to_ros_base_type(field_type) == "proto2ros/AnyProto": + type_name = "some" + field.annotations["proto-type"] = type_name.strip(".") + leading_comments = extract_leading_comments(location) + if leading_comments: + field.annotations["comment"] = leading_comments + field.annotations["deprecated"] = descriptor.options.deprecated + return field + + +@compute_equivalence.register +def compute_equivalence_for_message( + descriptor: DescriptorProto, source: FileDescriptorProto, location: SourceCodeInfo.Location, config: Configuration +) -> Equivalence: + """ + Computes a suitable equivalence relation for a Protobuf message type. + + Currently, this function supports optional, repeated, and oneof fields of primitive, enum, + map, message, and `google.protobuf.Any` type. Recursive or cyclic type dependencies may ensue + if these are present in the Protobuf message type (see `proto2ros.dependencies` on how to cope + with this). + + Args: + descriptor: the descriptor of the Protobuf message of interest. + source: the descriptor of the Protobuf source file where the Protobuf message is defined. + location: the location of the Protobuf message in the aforementioned source file. + config: a suitable configuration for the procedure. + + Returns: + an equivalence relation. + + Raises: + ValueError: when there are too many fields (more than 64) and + their availability cannot be encoded in the equivalent ROS message. + """ + ros_package_name = config.package_mapping[source.package] + name = equivalent_ros_name(source, location) + auxiliary_message_specs: List[MessageSpecification] = [] + + fields: List[Field] = [] + constants: List[Constant] = [] + oneof_field_sets: List[List[Field]] = [list() for _ in descriptor.oneof_decl] + + if not descriptor.options.map_entry: + if len(descriptor.field) > 0: + options_mask_size = max(2 ** math.ceil(math.log2(len(descriptor.field))), 8) + if options_mask_size > 64: + raise ValueError("too many fields (> 64)") + options_mask_type = Type(f"uint{options_mask_size}") + + # Iterate over all message fields, as listed by the given descriptor. See + # https://github.com/protocolbuffers/protobuf/blob/main/src/google/protobuf/descriptor.proto#L127 + # for further reference. + for i, (field_path, field_descriptor) in enumerate(locate_repeated("field", descriptor)): + if field_descriptor.options.deprecated and config.drop_deprecated: + continue + + field_location = resolve(source, field_path, location) + field = translate_field(field_descriptor, source, field_location, config) + if field_descriptor.HasField("oneof_index"): + oneof_field_sets[field_descriptor.oneof_index].append(field) + continue + + if field.annotations["optional"]: + mask_name = field.name.upper() + "_FIELD_SET" + mask_constant = Constant(str(options_mask_type), mask_name, 1 << i) + constants.append(mask_constant) + fields.append(field) + + for (oneof_path, oneof_decl), oneof_fields in zip(locate_repeated("oneof_decl", descriptor), oneof_field_sets): + oneof_name = inflection.underscore(oneof_decl.name) + oneof_type_name = inflection.camelize(f"{name}_one_of_{oneof_name}") + oneof_type = Type(f"{ros_package_name}/{oneof_type_name}") + + oneof_constants: List[Constant] = [] + oneof_constants.append(Constant("int8", f"{oneof_name}_not_set".upper(), 0)) + for i, field in enumerate(oneof_fields, start=1): + tag_name = f"{oneof_name}_{field.name}_set".upper() + oneof_constants.append(Constant("int8", tag_name, i)) + choice_field = Field(Type("int8"), f"{oneof_name}_choice") + choice_field.annotations["deprecated"] = True + oneof_fields.append(choice_field) + which_field = Field(Type("int8"), "which") + which_field.annotations["alias"] = choice_field.name + oneof_fields.append(which_field) + + oneof_message_spec = MessageSpecification( + oneof_type.pkg_name, oneof_type.type, oneof_fields, oneof_constants + ) + oneof_message_spec.annotations["proto-type"] = protofqn(source, location) + f"[one-of {oneof_decl.name}]" + oneof_message_spec.annotations["proto-class"] = "one-of" + oneof_message_spec.annotations["tagged"] = list(zip(oneof_constants[1:], oneof_fields[:-1])) + oneof_message_spec.annotations["tag"] = which_field + auxiliary_message_specs.append(oneof_message_spec) + + field = Field(oneof_type, oneof_name) + # oneof wrapper field cannot itself be optional + field.annotations["optional"] = False + oneof_location = resolve(source, oneof_path, location) + leading_comments = extract_leading_comments(oneof_location) + if leading_comments: + field.annotations["comment"] = leading_comments + fields.append(field) + + if len(constants) > 0: + field = Field(options_mask_type, "has_field", 2**options_mask_size - 1) + field.annotations["optional"] = False # field presence mask must always be present + fields.append(field) + else: + for i, (field_path, field_descriptor) in enumerate(locate_repeated("field", descriptor)): + if field_descriptor.options.deprecated and config.drop_deprecated: + continue + field_location = resolve(source, field_path, location) + field = translate_field(field_descriptor, source, field_location, config) + field.annotations["optional"] = False # map key-value pairs must always be present + if field.name == "value": + map_inplace = field_descriptor.type == FieldDescriptorProto.TYPE_MESSAGE + fields.append(field) + + message_spec = MessageSpecification(ros_package_name, name, fields, constants) + leading_comments = extract_leading_comments(location) + if leading_comments: + message_spec.annotations["comment"] = leading_comments + message_spec.annotations["proto-type"] = protofqn(source, location) + message_spec.annotations["proto-class"] = "message" + message_spec.annotations["has-optionals"] = len(constants) > 0 + message_spec.annotations["map-entry"] = descriptor.options.map_entry + if descriptor.options.map_entry: + message_spec.annotations["map-inplace"] = map_inplace + return Equivalence( + proto_spec=descriptor, message_spec=message_spec, auxiliary_message_specs=auxiliary_message_specs + ) + + +@functools.singledispatch +def extract_equivalences(descriptor: Any, *args: Any) -> Iterable[Equivalence]: + """ + Extracts equivalence relations for all Protobuf composite types in some Protobuf descriptor. + + Note this function operates as the entrypoint to all corresponding overloads (via single dispatch). + + Args: + descriptor: the descriptor bearing Protobuf composite types. + + Returns: + an iterable over equivalence relations. + """ + raise NotImplementedError(f"not implemented for {descriptor}") + + +@extract_equivalences.register +def extract_equivalences_from_message( + message_descriptor: DescriptorProto, + source_descriptor: FileDescriptorProto, + location: SourceCodeInfo.Location, + config: Configuration, +) -> Iterable[Equivalence]: + """ + Extracts equivalence relations for a Protobuf message type and all nested composite types (if any). + + Args: + message_descriptor: the descriptor of the Protobuf message of interest. + source_descriptor: the descriptor of the Protobuf source file where the Protobuf message is defined. + location: the location of the Protobuf message in the aforementioned source file. + config: a suitable configuration for the procedure. + + Returns: + an iterable over equivalence relations. + """ + yield compute_equivalence_for_message(message_descriptor, source_descriptor, location, config) + for enum_path, enum_descriptor in locate_repeated("enum_type", message_descriptor): + enum_location = resolve(source_descriptor, enum_path, location) + yield compute_equivalence_for_enum(enum_descriptor, source_descriptor, enum_location, config) + for nested_path, nested_descriptor in locate_repeated("nested_type", message_descriptor): + nested_location = resolve(source_descriptor, nested_path, location) + yield from extract_equivalences_from_message(nested_descriptor, source_descriptor, nested_location, config) + + +@extract_equivalences.register +def extract_equivalences_from_source( + source_descriptor: FileDescriptorProto, config: Configuration +) -> Iterable[Equivalence]: + """ + Extracts all equivalence relations from a Protobuf source descriptor. + + Args: + source: the descriptor of the Protobuf source file. + config: a suitable configuration for the procedure. + + Returns: + an iterable over equivalence relations. + """ + for enum_path, enum_type in locate_repeated("enum_type", source_descriptor): + enum_location = resolve(source_descriptor, enum_path) + yield compute_equivalence_for_enum(enum_type, source_descriptor, enum_location, config) + for message_path, message_type in locate_repeated("message_type", source_descriptor): + message_location = resolve(source_descriptor, message_path) + yield from extract_equivalences_from_message(message_type, source_descriptor, message_location, config) diff --git a/proto2ros/proto2ros/output/__init__.py b/proto2ros/proto2ros/output/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/proto2ros/proto2ros/output/interfaces.py b/proto2ros/proto2ros/output/interfaces.py new file mode 100644 index 0000000..d0dc79a --- /dev/null +++ b/proto2ros/proto2ros/output/interfaces.py @@ -0,0 +1,67 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +"""This module provides APIs to write ROS message specifications.""" + +import os +import pathlib +from typing import List, Optional, Union + +from rosidl_adapter.parser import BaseType, Field, MessageSpecification, Type + + +def dump_as_comments(content: str) -> str: + """Dumps the given content as ROS message comment.""" + return "\n".join("#" + line for line in content.splitlines()) + + +def dump_message_specification(message_spec: MessageSpecification) -> str: + """Dumps a ROS message specification back to a string.""" + output = [] + if "comment" in message_spec.annotations: + comment = message_spec.annotations["comment"] + output.append(dump_as_comments(comment)) + if message_spec.constants: + output.append("") # blank line + for constant in message_spec.constants: + if "comment" in constant.annotations: + comment = constant.annotations["comment"] + output.append(dump_as_comments(comment)) + output.append(str(constant)) + if message_spec.fields: + output.append("") # blank line + for field in message_spec.fields: + qualifiers: List[str] = [] + if "comment" in field.annotations: + comment = field.annotations["comment"] + output.append(dump_as_comments(comment)) + if field.annotations.get("deprecated"): + qualifiers.append("deprecated") + if field.annotations.get("type-erased"): + qualifiers.append(f"is {field.type} (type-erased)") + any_type = Type(str(field.type).replace(BaseType.__str__(field.type), "proto2ros/Any")) + field = Field(any_type, field.name) + line = str(field) + if qualifiers: + line += " # " + ", ".join(qualifiers) + output.append(line) + return "\n".join(output) + + +def which_message_specification( + message_spec: MessageSpecification, root: Optional[Union[str, os.PathLike[str]]] = None +) -> pathlib.Path: + """ + Returns an .msg file path for a given ROS message specification. + + ROS .msg file name conversions are observed in the process. + + Args: + message_spec: source ROS message specification. + root: optional root directory for .msg file. + + Returns: + the full .msg file path. + """ + if root is None: + root = "." + return pathlib.Path(root) / f"{message_spec.msg_name}.msg" diff --git a/proto2ros/proto2ros/output/python.py b/proto2ros/proto2ros/output/python.py new file mode 100644 index 0000000..0a12334 --- /dev/null +++ b/proto2ros/proto2ros/output/python.py @@ -0,0 +1,151 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +"""This module provides APIs to write Python conversion code.""" + +import importlib +import os +import pathlib +import sys +from typing import Dict, Iterable, List, Optional, Union + +import inflection +import jinja2 +from google.protobuf.descriptor import Descriptor +from rosidl_adapter.parser import BaseType, MessageSpecification + +from proto2ros.configuration import Configuration +from proto2ros.utilities import fqn, to_ros_base_type + + +def to_python_module_name(module_path: os.PathLike) -> str: + """Returns a Python module name given its source file path.""" + basename, _ = os.path.splitext(module_path) + return basename.replace("/", ".") + + +def to_python_identifier(string: str) -> str: + """Transforms an arbitrary string into a valid Python identifier.""" + return inflection.underscore(string.replace("/", "_").replace(".", "_")) + + +def itemize_python_identifier(identifier: str, prefix: Optional[str] = None) -> str: + """Derives a loop variable identifier for its iterable variable identifier.""" + _, _, basename = identifier.rpartition(".") + if prefix and not basename.startswith(prefix): + basename = prefix + basename + if not basename.endswith("item"): + return f"{basename}_item" + return basename.removesuffix("item") + "subitem" + + +def to_ros_python_module_name(package_name: str) -> str: + """Returns the Python module name for a given ROS message package.""" + return f"{package_name}.msg" + + +def to_ros_python_type(type_name: Union[str, BaseType]) -> str: + """Returns the Python class name for a given ROS message type.""" + package_name, _, name = str(type_name).rpartition("/") + if not package_name: + raise ValueError(f"no package name in {type_name}") + if not name: + raise ValueError(f"no message name in {type_name}") + return f"{to_ros_python_module_name(package_name)}.{name}" + + +def to_pb2_python_module_name(source_path: os.PathLike) -> str: + """Returns the Python module name for a given Protobuf source file.""" + basename, _ = os.path.splitext(source_path) + return to_python_module_name(basename + "_pb2.py") + + +def build_pb2_python_type_lut(module_names: Iterable[str], inline_module_names: Iterable[str]) -> Dict[str, str]: + """ + Builds a lookup table to go from Protobuf message type names to Python class name. + + For instance, if `google.protobuf.any_pb2` is listed as an imported module name and + `google.protobuf.timestamp_pb2` is listed as an inline imported module name, the + resulting lookup table will include entries such as: + + ``{"google.protobuf.Any": "google.protobuf.any_pb2.Any", "google.protobuf.timestamp_pb2.Timestamp": "Timestamp"}`` + + where Python types for Protobuf messages coming from inline imports are unqualified. + + Args: + module_names: names of Python modules to traverse in order to map fully qualified + Protobuf message type names to fully qualified Python class names (assuming these + modules will be imported like ``import ``). + inline_module_names: names of Python modules to traverse in order to map fully + qualified Protobuf message type names to unqualified Python class names (assuming + these modules will be imported like ``from import *``). + + Returns: + a lookup table as a simple dict. + """ + pb2_python_type_lut: Dict[str, str] = {} + module_names = {name for name in module_names if name.endswith("_pb2")} + inline_module_names = {name for name in inline_module_names if name.endswith("_pb2")} + for module_name in (*module_names, *inline_module_names): + module = importlib.import_module(module_name) + attributes = [ + (fqn(value), value) if module_name in module_names else (name, value) + for name, value in module.__dict__.items() + if hasattr(value, "DESCRIPTOR") + ] + while attributes: + name, value = attributes.pop(0) + if not isinstance(value.DESCRIPTOR, Descriptor): + continue + assert name is not None + proto_name = value.DESCRIPTOR.full_name + pb2_python_type_lut[proto_name] = name + attributes.extend( + (f"{name}.{nested_name}", nested_value) + for nested_name, nested_value in value.__dict__.items() + if hasattr(nested_value, "DESCRIPTOR") + ) + del sys.modules[module.__name__] + return pb2_python_type_lut + + +def which_conversions_python_module(root: Optional[Union[str, os.PathLike[str]]] = None) -> pathlib.Path: + """ + Returns a suitable .py file path for Protobuf <-> ROS conversion APIs. + + Args: + output_directory: directory where to write the .py source. Directory is assumed to exist. + + Returns: + the full .py file path. + """ + if root is None: + root = "." + return pathlib.Path(root) / "conversions.py" + + +def dump_conversions_python_module(message_specifications: List[MessageSpecification], config: Configuration) -> str: + """ + Dumps the Python module source for Protobuf <-> ROS conversion APIs. + + Args: + message_specifications: annotated ROS message specifications, + as derived from equivalence relations (see `proto2ros.equivalences`). + config: a suitable configuration for the procedure. + + Returns: + the conversion Python module source. + """ + env = jinja2.Environment(loader=jinja2.PackageLoader("proto2ros.output")) + env.globals["to_ros_base_type"] = to_ros_base_type + env.globals["itemize_python_identifier"] = itemize_python_identifier + env.filters["as_python_identifier"] = to_python_identifier + env.filters["as_ros_python_type"] = to_ros_python_type + pb2_python_type_lut = build_pb2_python_type_lut(config.python_imports, config.inline_python_imports) + + def lookup_pb2_python_type(type_name: str) -> str: + return pb2_python_type_lut[type_name] + + env.filters["as_pb2_python_type"] = lookup_pb2_python_type + env.filters["as_ros_base_type"] = to_ros_base_type + python_conversions_template = env.get_template("conversions.py.jinja") + return python_conversions_template.render(message_specifications=message_specifications, config=config) diff --git a/proto2ros/proto2ros/output/templates/__init__.py b/proto2ros/proto2ros/output/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/proto2ros/proto2ros/output/templates/conversions.py.jinja b/proto2ros/proto2ros/output/templates/conversions.py.jinja new file mode 100644 index 0000000..55047ee --- /dev/null +++ b/proto2ros/proto2ros/output/templates/conversions.py.jinja @@ -0,0 +1,289 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# See `conversions.py.jinja` template in the `proto2ros.output.templates` Python module. +import rclpy.serialization + +{% for module_name in config.python_imports -%} +import {{ module_name }} +{% endfor %} +from proto2ros.conversions import convert +{% for module_name in config.inline_python_imports -%} +from {{ module_name }} import * +{% endfor -%} + +{# + Expands to code for ROS to Protobuf field conversion. + + Args: + source: identifier of the ROS message field to convert from. + destination: identifier of the Protobuf message field to convert to. + spec: annotated ROS message field specification. +#} +{%- macro ros_to_proto_field_code(source, destination, spec) -%} + {%- if not spec.type.is_primitive_type() -%} + {%- set type_spec = config.known_message_specifications.get(to_ros_base_type(spec.type)) -%} + {%- if type_spec and type_spec.annotations.get("proto-class") == "enum" -%} + {#- Handle enum case i.e. extract integral values from ROS wrapper messages. -#} + {%- if spec.type.is_array -%} +{{ destination }}.extend(item.value for item in {{ source }}) + {%- else -%} +{{ destination }} = {{ source }}.value + {%- endif -%} + {%- elif spec.type.is_array -%} + {%- set input_item = itemize_python_identifier(source, "input_") -%} + {%- set output_item = itemize_python_identifier(destination, "output_") -%} + {%- if type_spec and type_spec.annotations.get("map-entry") -%} + {#- Handle map case i.e. convert map entries, then update Protobuf messsage map. -#} +{{ output_item }} = {{ type_spec.annotations["proto-type"] | as_pb2_python_type }}() +for {{ input_item }} in {{ source }}: + {{ ros_to_proto_composite_field_code_block(input_item, output_item, spec) | indent(4) }} + {%- if type_spec.annotations.get("map-inplace") -%} + {#- Then map value cannot be assigned, must be copied. #} + {{ destination }}[{{ output_item }}.key].CopyFrom({{ output_item }}.value) + {%- else %} + {{ destination }}[{{ output_item }}.key] = {{ output_item }}.value + {%- endif %} + {%- else -%}{#- Handle sequence case i.e. just convert sequence items. #} +for {{ input_item }} in {{ source }}: + {{ output_item }} = {{ destination }}.add() + {{ ros_to_proto_composite_field_code_block(input_item, output_item, spec) | indent(4) }} + {%- endif -%} + {%- else -%}{#- Handle message case. -#} +{{ ros_to_proto_composite_field_code_block(source, destination, spec) }} + {%- endif -%} + {%- elif spec.annotations["proto-type"] == "bytes" -%} + {#- Handle bytes case. -#} + {%- if to_ros_base_type(spec.type) == "proto2ros/Bytes" -%} +{{ destination }}.extend(blob.data.tobytes() for blob in {{ source }}) + {%- else -%} +{{ destination }} = {{ source }}.tobytes() + {%- endif -%} + {%- else -%}{#- Handle primitive types case. -#} + {%- if spec.type.is_array -%} +{{ destination }}.extend({{ source }}) + {%- else -%} +{{ destination }} = {{ source }} + {%- endif -%} + {%- endif -%} +{%- endmacro -%} + +{# + Expands to a code block for ROS to Protobuf composite field conversion. + + Args: + source: identifier for the ROS message field to convert from. + destination: identifier for the Protobuf message field to convert to. + spec: annotated ROS message field specification. + + Note: code block may expand within a for-loop, during a repeated field expansion. +#} +{%- macro ros_to_proto_composite_field_code_block(source, destination, spec) -%} + {%- set type_spec = config.known_message_specifications.get(to_ros_base_type(spec.type)) -%} + {%- if spec.annotations.get("type-erased") -%} + {#- ROS message must be deserialized for conversion. -#} +if {{ source }}.type_name != "{{ spec.type | as_ros_base_type }}": + raise ValueError("expected {{ spec.type | as_ros_base_type }} message for {{ spec.name }} member, got %s" % {{ source }}.type) +typed_field_message = rclpy.serialization.deserialize_message({{ source }}.value.tobytes(), {{ spec.type | as_ros_base_type | as_ros_python_type }}) +convert_{{ spec.type | as_ros_base_type | as_python_identifier }}_message_to_{{ spec.annotations["proto-type"] | as_python_identifier }}_proto(typed_field_message, {{ destination }}) + {%- elif spec.annotations.get("type-casted") -%} + {#- ROS message must be converted and packed for assignment. -#} +typed_proto_message = {{ proto_type_name | as_pb2_python_type }}() +convert_{{ spec.type | as_ros_base_type | as_python_idenfier }}_message_to_{{ proto_type_name | as_python_identifier }}_proto({{ source }}, typed_proto_message) +{{ destination }}.Pack(typed_proto_message) + {%- elif spec.annotations.get("type-casts") -%} + {#- ROS message must be deserialized according to type, then converted, then packed for assignment. -#} +match {{ source }}.type_name: + {% for proto_type_name, ros_type_name in spec.annotations["type-casts"] -%} + case "{{ ros_type_name }}": + typed_field_message = rclpy.serialization.deserialize_message( + {{ source }}.value.tobytes(), {{ ros_type_name | as_ros_python_type }}) + typed_proto_message = {{ proto_type_name | as_pb2_python_type }}() + convert_{{ ros_type_name | as_python_identifier }}_message_to_{{ proto_type_name | as_python_identifier }}_proto(typed_field_message, typed_proto_message) + {{ destination }}.Pack(typed_proto_message) + {% endfor -%} + case _: + raise ValueError("unexpected %s in {{ spec.name }} member:" % ros_msg.{{ spec.name }}.type) + {%- elif type_spec and type_spec.annotations.get("tagged") -%} + {#- Handle one-of field case i.e. determine and convert the ROS message member that is set. -#} + {%- set tag_field_spec = type_spec.annotations["tag"] -%} +match {{ source }}.{{ tag_field_spec.name }} or {{ source }}.{{ tag_field_spec.annotations["alias"] }}: + {%- for tag_spec, member_spec in type_spec.annotations["tagged"] %} + case {{ type_spec.base_type | string | as_ros_python_type }}.{{ tag_spec.name }}: + {%- set source_member = source + "." + member_spec.name -%} + {%- set destination_member = destination.rpartition(".")[0] + "." + member_spec.annotations.get("proto-name", member_spec.name) %} + {{ ros_to_proto_field_code(source_member, destination_member, member_spec) | indent(8) }} + {%- endfor %} + case _: + pass + {%- else -%} + {#- Handle the generic ROS message case (because it is appropriate or because we do not know any better). -#} +convert_{{ spec.type | as_ros_base_type | as_python_identifier }}_message_to_{{ spec.annotations["proto-type"] | as_python_identifier }}_proto({{ source }}, {{ destination }}) + {%- endif -%} +{%- endmacro -%} + +{# + Expands to code for Protobuf to ROS field conversion. + + Args: + source: identifier of the Protobuf message field to convert from. + destination: identifier of the ROS message field to convert to. + spec: annotated ROS message field specification. +#} +{%- macro proto_to_ros_field_code(source, destination, spec) -%} + {%- if not spec.type.is_primitive_type() -%} + {%- set type_spec = config.known_message_specifications.get(to_ros_base_type(spec.type)) -%} + {%- if type_spec and type_spec.annotations.get("proto-class") == "enum" -%} + {#- Handle enum case i.e. wrap integral values in ROS messages. -#} + {%- if spec.type.is_array -%} +{{ destination }} = [{{ spec.type | as_ros_base_type | as_ros_python_type }}(value=value) for value in {{ source }}] + {%- else -%} +{{ destination }}.value = {{ source }} + {%- endif -%} + {%- elif spec.type.is_array -%} + {%- set input_item = itemize_python_identifier(source, "input_") -%} + {%- set output_item = itemize_python_identifier(destination, "output_") -%} + {%- if type_spec and type_spec.annotations.get("map-entry") %} + {#- Handle map case i.e. reconstruct map entries, the convert, then assign. -#} + {%- set input_iterable = input_item + "_entries" -%} +{{ input_iterable }} = [{{ type_spec.annotations["proto-type"] | as_pb2_python_type }}(key=key, value=value) for key, value in {{ source }}.items()] + {%- else -%} + {#- Handle sequence case. -#} + {%- set input_iterable = source -%} + {%- endif %} +for {{ input_item }} in {{ input_iterable }}: + {%- if not spec.annotations.get("type-erased") %} + {{ output_item }} = {{ spec.type | as_ros_base_type | as_ros_python_type }}() + {%- else %}{#- It must convert to a type erased ROS message. #} + {{ output_item }} = proto2ros.msg.Any() + {%- endif %} + {{ proto_to_ros_composite_field_code_block(input_item, output_item, spec) | indent(4) }} + {{ destination }}.append({{ output_item }}) + {%- else -%}{#- Handle message case. -#} +{{ proto_to_ros_composite_field_code_block(source, destination, spec) }} + {%- endif -%} + {%- elif spec.annotations["proto-type"] == "bytes" -%} + {#- Handle bytes case. -#} + {%- if to_ros_base_type(spec.type) == "proto2ros/Bytes" -%} +{{ destination }} = [proto2ros.msg.Bytes(data=blob) for blob in {{ source }}] + {%- else -%} +{{ destination }} = {{ source }} + {%- endif -%} + {%- else -%}{#- Handle primitive types case. -#} +{{ destination }} = {{ source }} + {%- endif -%} +{%- endmacro -%} + +{# + Expands to a code block for Protobuf to ROS composite field conversion. + + Args: + source: identifier of the Protobuf message field to convert from. + destination: identifier of the ROS message field to convert to. + spec: annotated ROS message field specification. + + Note: code block may expand within a for-loop, during a repeated field expansion. +#} +{%- macro proto_to_ros_composite_field_code_block(source, destination, spec) -%} + {%- set type_spec = config.known_message_specifications.get(to_ros_base_type(spec.type)) -%} + {%- if spec.annotations.get("type-erased") -%} + {#- ROS message must be serialized for assignment. -#} +typed_field_message = {{ spec.type | as_ros_base_type | as_ros_python_type }}() +convert_{{ spec.annotations["proto-type"] | as_python_identifier }}_proto_to_{{ spec.type | as_ros_base_type | as_python_identifier }}_message({{ source }}, typed_field_message) +{{ destination }}.value = rclpy.serialization.serialize_message(typed_field_message) +{{ destination }}.type_name = "{{ spec.type | as_ros_base_type }}" + {%- elif spec.annotations.get("type-casted") -%} + {#- Protobuf message must be unpacked for conversion. -#} +typed_proto_message = {{ field_spec.annotations["proto-type"] | as_pb2_python_type }}() +{{ source }}.Unpack(typed_proto_message) +convert_{{ spec.annotations["proto-type"] | as_python_identifier }}_proto_to_{{ spec.type | as_ros_base_type | as_python_identifier }}_message(typed_proto_message, {{ destination }}) + {%- elif spec.annotations.get("type-casts") -%} + {#- Protobuf message must be unpacked according to type, then converted, then serialized for assignment. -#} + {%- for proto_type_name, ros_type_name in spec.annotations["type-casts"] -%} + {%- if loop.first -%} +if {{ source }}.Is({{ proto_type_name | as_pb2_python_type }}): + {%- else -%} +elif {{ source }}.Is({{ proto_type_name | as_pb2_python_type }}): + {%- endif -%} + typed_proto_message = {{ proto_type_name | as_pb2_python_type }}() + ok = {{ source }}.Unpack(typed_proto_message) + assert ok, "Failed to unpack any protobuf, internal error" + typed_field_message = {{ ros_type_name | as_ros_python_type }}() + convert_{{ proto_type_name | as_python_identifier }}_proto_to_{{ ros_type_name | as_python_identifier }}_message(typed_proto_message, typed_field_message) + {{ destination }}.value = rclpy.serialization.serialize_message(typed_field_message) + {{ destination }}.type_name = "{{ ros_type_name }}" + {%- endfor -%} +else: + raise ValueError("unknown protobuf message type in {{ spec.name }} member: %s" %s {{ source }}.type_url) + {%- elif type_spec and type_spec.annotations.get("tagged") -%} + {#- Handle one-of field case i.e. determine and convert the Protobuf message member that is set. -#} +match {{ source.rpartition(".")[0] }}.WhichOneof("{{ spec.name }}"): + {%- set tag_field_spec = type_spec.annotations["tag"] -%} + {%- for tag_spec, member_spec in type_spec.annotations["tagged"] %} + case "{{ member_spec.name }}": + {%- set source_member = source.rpartition(".")[0] + "." + member_spec.annotations.get("proto-name", member_spec.name) -%} + {%- set destination_member = destination + "." + member_spec.name %} + {{ proto_to_ros_field_code(source_member, destination_member, member_spec) | indent(8) }} + {{ destination }}.{{ tag_field_spec.name }} = {{ type_spec.base_type | string | as_ros_python_type }}.{{ tag_spec.name }} + {{ destination }}.{{ tag_field_spec.annotations["alias"] }} = {{ destination }}.{{ tag_field_spec.name }} + {%- endfor %} + case _: + {{ destination }}.{{ tag_field_spec.name }} = 0 + {{ destination }}.{{ tag_field_spec.annotations["alias"] }} = 0 + {%- else -%} + {#- Handle the generic Protobuf message case (because it is appropriate or because we do not know any better). -#} +convert_{{ spec.annotations["proto-type"] | as_python_identifier }}_proto_to_{{ spec.type | as_ros_base_type | as_python_identifier }}_message({{ source }}, {{ destination }}) + {%- endif -%} +{%- endmacro %} +{% for spec in message_specifications if spec.annotations.get("proto-class") == "message" %} +@convert.register({{ spec.base_type | string | as_ros_python_type }}, {{ spec.annotations["proto-type"] | as_pb2_python_type }}) +def convert_{{ spec.base_type | string | as_python_identifier }}_message_to_{{ spec.annotations["proto-type"] | as_python_identifier }}_proto( + ros_msg: {{ spec.base_type | string | as_ros_python_type }}, proto_msg: {{ spec.annotations["proto-type"] | as_pb2_python_type }} +) -> None: + """Converts from {{ spec.base_type }} ROS messages to {{ spec.annotations["proto-type"] }} Protobuf messages.""" + {%- if spec.fields %} + proto_msg.Clear() + {%- for field_spec in spec.fields if field_spec.name != "has_field" %} + {%- set source = "ros_msg." + field_spec.name -%} + {%- set destination = "proto_msg." + field_spec.annotations.get("proto-name", field_spec.name) -%} + {%- if field_spec.annotations["optional"] %}{#- Check for field presence before use. #} + if ros_msg.has_field & {{ spec.base_type | string | as_ros_python_type }}.{{ field_spec.name | upper }}_FIELD_SET: + {{ ros_to_proto_field_code(source, destination, field_spec) | indent(8) }} + {%- else %} + {{ ros_to_proto_field_code(source, destination, field_spec) | indent(4) }} + {%- endif -%} + {% endfor %} + {% else -%}{#- Handle empty message. #} + pass + {% endif %} + +convert_{{ spec.base_type | string | as_python_identifier }}_to_proto = \ + convert_{{ spec.base_type | string | as_python_identifier }}_message_to_{{ spec.annotations["proto-type"] | as_python_identifier }}_proto + + +@convert.register({{ spec.annotations["proto-type"] | as_pb2_python_type }}, {{ spec.base_type | string | as_ros_python_type }}) +def convert_{{ spec.annotations["proto-type"] | as_python_identifier }}_proto_to_{{ spec.base_type | string | as_python_identifier }}_message( + proto_msg: {{ spec.annotations["proto-type"] | as_pb2_python_type }}, ros_msg: {{ spec.base_type | string | as_ros_python_type }} +) -> None: + """Converts from {{ spec.annotations["proto-type"] }} Protobuf messages to {{ spec.base_type }} ROS messages.""" + {%- if spec.fields %} + {%- if spec.annotations["has-optionals"] %} + ros_msg.has_field = 0 + {%- endif -%} + {%- for field_spec in spec.fields if field_spec.name != "has_field" -%} + {%- set source = "proto_msg." + field_spec.annotations.get("proto-name", field_spec.name) -%} + {%- set destination = "ros_msg." + field_spec.name -%} + {%- if field_spec.annotations["optional"] -%}{#- Check for field presence before use. #} + if proto_msg.HasField("{{ field_spec.annotations.get("proto-name", field_spec.name) }}"): + {{ proto_to_ros_field_code(source, destination, field_spec) | indent(8) }} + ros_msg.has_field |= {{ spec.base_type | string | as_ros_python_type }}.{{ field_spec.name | upper }}_FIELD_SET + {%- else %} + {{ proto_to_ros_field_code(source, destination, field_spec) | indent(4) }} + {%- endif -%} + {% endfor %} + {% else -%}{#- Handle empty message. #} + pass + {% endif %} + +convert_proto_to_{{ spec.base_type | string | as_python_identifier }} = \ + convert_{{ spec.annotations["proto-type"] | as_python_identifier }}_proto_to_{{ spec.base_type | string | as_python_identifier }}_message + +{% endfor -%} diff --git a/proto2ros/proto2ros/utilities.py b/proto2ros/proto2ros/utilities.py new file mode 100644 index 0000000..8288d13 --- /dev/null +++ b/proto2ros/proto2ros/utilities.py @@ -0,0 +1,163 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +"""This module provides various utilities used across the proto2ros package.""" + +import collections +import functools +import itertools +import keyword +from typing import Any, Callable, Iterable, Optional, Tuple, Union + +from rosidl_adapter.parser import BaseType, Type + + +def identity_lru_cache(maxsize: int = 128) -> Callable[[Callable], Callable]: + """A `functools.lru_cache` equivalent that uses objects' id() for result caching.""" + + def _decorator(user_function: Callable) -> Callable: + cache: collections.OrderedDict = collections.OrderedDict() + + @functools.wraps(user_function) + def _wrapper(*args: Any, **kwargs: Any) -> Any: + nonlocal cache + if len(cache) + 1 >= maxsize: + cache.popitem(last=False) + key = (tuple(id(arg) for arg in args), tuple((key, id(value)) for key, value in kwargs.items())) + if key not in cache: + cache[key] = user_function(*args, **kwargs) + return cache[key] + + return _wrapper + + return _decorator + + +def fqn(obj: Any) -> Optional[str]: + """Computes the fully qualified name of a given object, if any.""" + if not hasattr(obj, "__qualname__"): + return None + name = obj.__qualname__ + if not hasattr(obj, "__module__"): + return name + module = obj.__module__ + return f"{module}.{name}" + + +def pairwise(iterable: Iterable[Any]) -> Iterable[Tuple[Any, Any]]: + """Yields an iterable over consecutive pairs.""" + a, b = itertools.tee(iterable) + next(b, None) + return zip(a, b) + + +def to_ros_base_type(type_: Union[str, BaseType]) -> str: + """Returns base type name for a given ROS type.""" + return BaseType.__str__(Type(str(type_))) + + +PYTHON_RESERVED_KEYWORD_SET = set(keyword.kwlist) +CPP_RESERVED_KEYWORD_SET = { + "NULL", + "alignas", + "alignof", + "and", + "and_eq", + "asm", + "assert", + "auto", + "bitand", + "bitor", + "bool", + "break", + "case", + "catch", + "char", + "class", + "compl", + "const", + "constexpr", + "const_cast", + "continue", + "decltype", + "default", + "delete", + "do", + "double", + "dynamic_cast", + "else", + "enum", + "explicit", + "export", + "extern", + "false", + "float", + "for", + "friend", + "goto", + "if", + "inline", + "int", + "long", + "mutable", + "namespace", + "new", + "noexcept", + "not", + "not_eq", + "nullptr", + "operator", + "or", + "or_eq", + "private", + "protected", + "public", + "register", + "reinterpret_cast", + "return", + "short", + "signed", + "sizeof", + "static", + "static_assert", + "static_cast", + "struct", + "switch", + "template", + "this", + "thread_local", + "throw", + "true", + "try", + "typedef", + "typeid", + "typename", + "union", + "unsigned", + "using", + "virtual", + "void", + "volatile", + "wchar_t", + "while", + "xor", + "xor_eq", + "char8_t", + "char16_t", + "char32_t", + "concept", + "consteval", + "constinit", + "co_await", + "co_return", + "co_yield", + "requires", +} +RESERVED_KEYWORD_SET = PYTHON_RESERVED_KEYWORD_SET | CPP_RESERVED_KEYWORD_SET + + +def to_ros_field_name(name: str) -> str: + """Transform a given name to be a valid ROS message field name.""" + name = name.lower() + if name in RESERVED_KEYWORD_SET: + name = name + "_field" + return name diff --git a/proto2ros/setup.cfg b/proto2ros/setup.cfg new file mode 100644 index 0000000..464b358 --- /dev/null +++ b/proto2ros/setup.cfg @@ -0,0 +1,4 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. +[options.entry_points] +console_scripts = + generate = proto2ros.cli.generate:main diff --git a/proto2ros_tests/CMakeLists.txt b/proto2ros_tests/CMakeLists.txt new file mode 100644 index 0000000..868dbcc --- /dev/null +++ b/proto2ros_tests/CMakeLists.txt @@ -0,0 +1,58 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. +cmake_minimum_required(VERSION 3.12) +project(proto2ros_tests) + +find_package(ament_cmake REQUIRED) +find_package(builtin_interfaces REQUIRED) +find_package(geometry_msgs REQUIRED) + +find_package(rosidl_default_generators REQUIRED) + +find_package(proto2ros REQUIRED) + +add_subdirectory(proto) + +proto2ros_generate( + ${PROJECT_NAME}_messages_gen + PROTOS proto/test.proto + CONFIG_OVERLAYS config/overlay.yaml + INTERFACES_OUT_VAR ros_messages + PYTHON_OUT_VAR py_sources + APPEND_PYTHONPATH "${PROTO_OUT_DIR}" +) +add_dependencies( + ${PROJECT_NAME}_messages_gen ${PROJECT_NAME}_proto_gen +) + +rosidl_generate_interfaces( + ${PROJECT_NAME} ${ros_messages} + DEPENDENCIES geometry_msgs builtin_interfaces proto2ros + SKIP_INSTALL +) +add_dependencies(${PROJECT_NAME} ${PROJECT_NAME}_messages_gen) + +rosidl_generated_python_package_add( + ${PROJECT_NAME}_additional_modules + MODULES ${proto_py_sources} ${py_sources} + PACKAGES ${PROJECT_NAME} + DESTINATION ${PROJECT_NAME} +) + +if(BUILD_TESTING) + find_package(ament_cmake_pytest REQUIRED) + get_rosidl_generated_interfaces_test_setup( + LIBRARY_DIRS additional_library_dirs + ENV additional_env + ) + list(APPEND additional_env "PYTHONPATH=${PROTO_OUT_DIR}") + + ament_add_pytest_test( + regression_tests test + APPEND_LIBRARY_DIRS ${additional_library_dirs} + APPEND_ENV ${additional_env} + ENV CMAKE_BINARY_DIR=${CMAKE_BINARY_DIR} + PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python + ) +endif() + +ament_package() diff --git a/proto2ros_tests/LICENSE b/proto2ros_tests/LICENSE new file mode 100644 index 0000000..aa696ca --- /dev/null +++ b/proto2ros_tests/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Boston Dynamics AI Institute + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/proto2ros_tests/config/overlay.yaml b/proto2ros_tests/config/overlay.yaml new file mode 100644 index 0000000..64f83e6 --- /dev/null +++ b/proto2ros_tests/config/overlay.yaml @@ -0,0 +1,11 @@ +message_mapping: + bosdyn.api.Vec3: geometry_msgs/Vector3 + bosdyn.api.Quaternion: geometry_msgs/Quaternion + bosdyn.api.SE3Pose: geometry_msgs/Pose + bosdyn.api.SE3Velocity: geometry_msgs/Twist + bosdyn.api.Wrench: geometry_msgs/Wrench +python_imports: + - bosdyn.api.geometry_pb2 + - geometry_msgs.msg +inline_python_imports: + - proto2ros_tests.manual_conversions diff --git a/proto2ros_tests/package.xml b/proto2ros_tests/package.xml new file mode 100644 index 0000000..2e71506 --- /dev/null +++ b/proto2ros_tests/package.xml @@ -0,0 +1,34 @@ + + + + + proto2ros_tests + 0.1.0 + Protobuf to ROS 2 interoperability interfaces tests + BD AI Institute + MIT + + ament_cmake + rosidl_default_generators + + protobuf + proto2ros + + builtin_interfaces + geometry_msgs + + python3-jinja2 + python3-networkx + python3-protobuf + python3-yaml + + ament_cmake_pytest + + rosidl_interface_packages + + + ament_cmake + + diff --git a/proto2ros_tests/proto/CMakeLists.txt b/proto2ros_tests/proto/CMakeLists.txt new file mode 100644 index 0000000..554a78e --- /dev/null +++ b/proto2ros_tests/proto/CMakeLists.txt @@ -0,0 +1,25 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. +find_package(Protobuf REQUIRED) + +file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/bosdyn/api") +file(TOUCH "${CMAKE_CURRENT_BINARY_DIR}/bosdyn/__init__.py") +file(TOUCH "${CMAKE_CURRENT_BINARY_DIR}/bosdyn/api/__init__.py") + +protobuf_generate( + LANGUAGE python + OUT_VAR bosdyn_proto_py_sources + PROTOS bosdyn/api/geometry.proto +) + +protobuf_generate( + LANGUAGE python + OUT_VAR proto_py_sources + PROTOS test.proto +) + +add_custom_target( + ${PROJECT_NAME}_proto_gen ALL DEPENDS + ${proto_py_sources} ${bosdyn_proto_py_sources} +) + +set(PROTO_OUT_DIR "${CMAKE_CURRENT_BINARY_DIR}" PARENT_SCOPE) diff --git a/proto2ros_tests/proto/bosdyn/api/geometry.proto b/proto2ros_tests/proto/bosdyn/api/geometry.proto new file mode 100644 index 0000000..1a934e5 --- /dev/null +++ b/proto2ros_tests/proto/bosdyn/api/geometry.proto @@ -0,0 +1,367 @@ +// Copyright (c) 2023 Boston Dynamics, Inc. All rights reserved. +// +// Downloading, reproducing, distributing or otherwise using the SDK Software +// is subject to the terms and conditions of the Boston Dynamics Software +// Development Kit License (20191101-BDSDK-SL). + +// Taken verbatim from https://raw.githubusercontent.com/boston-dynamics/spot-sdk/v3.3.0/protos/bosdyn/api/geometry.proto + +syntax = "proto3"; + +package bosdyn.api; + +import "google/protobuf/wrappers.proto"; + +option go_package = "bosdyn/api"; +option java_outer_classname = "GeometryProto"; + +// Two dimensional vector primitive. +message Vec2 { + double x = 1; + double y = 2; +} + +// Three dimensional vector primitive. +message Vec3 { + double x = 1; + double y = 2; + double z = 3; +} + +// Cylindrical coordinates are a generalization of polar coordiates, adding a +// height +// axis. See (http://mathworld.wolfram.com/CylindricalCoordinates.html) for +// more details. +message CylindricalCoordinate { + double r = 1; // Radial coordinate + double theta = 2; // Azimuthal coordinate + double z = 3; // Vertical coordiante +} + +// Quaternion primitive. A quaternion can be used to describe the rotation. +message Quaternion { + double x = 1; + double y = 2; + double z = 3; + double w = 4; +} + +// Plane primitive, described with a point and normal. +message Plane { + Vec3 point = 1; // A point on the plane. + Vec3 normal = 2; // The direction of the planes normal. +} + +// A square oriented in 3D space. +message Quad { + // The center of the quad and the orientation of the normal. + // The normal axis is [0, 0, 1]. + SE3Pose pose = 1; + // The side length of the quad. + double size = 2; +} + +// A ray in 3D space. +message Ray { + // Base of ray. + Vec3 origin = 1; + + // Unit vector defining the direction of the ray. + Vec3 direction = 2; +} + +// Geometric primitive to describe 2D position and rotation. +message SE2Pose { + Vec2 position = 1; // (m) + double angle = 2; // (rad) +} + +// Geometric primitive that describes a 2D velocity through it's linear and angular components. +message SE2Velocity { + Vec2 linear = 1; // (m/s) + double angular = 2; // (rad/s) +} + +// Geometric primitive to couple minimum and maximum SE2Velocities in a single message. +message SE2VelocityLimit { + // If set, limits the maximum velocity. + SE2Velocity max_vel = 1; + // If set, limits the minimum velocity. + SE2Velocity min_vel = 2; +} + +// Geometric primitive to describe 3D position and rotation. +message SE3Pose { + Vec3 position = 1; // (m) + Quaternion rotation = 2; +} + +// Geometric primitive that describes a 3D velocity through it's linear and angular components. +message SE3Velocity { + Vec3 linear = 1; // (m/s) + Vec3 angular = 2; // (rad/s) +} + +// Geometric primitive used to specify forces and torques. +message Wrench { + Vec3 force = 1; // (N) + Vec3 torque = 2; // (Nm) +} + +message FrameTreeSnapshot { + /** + * A frame is a named location in space. \ + * For example, the following frames are defined by the API: \ + * - "body": A frame centered on the robot's body. \ + * - "vision": A non-moving (inertial) frame that is the robot's best + * estimate of a fixed location in the world. It is based on + * both dead reckoning and visual analysis of the world. \ + * - "odom": A non-moving (inertial) frame that is based on the kinematic + * odometry of the robot only. \ + * Additional frames are available for robot joints, sensors, and items + * detected in the world. \ + * + * The FrameTreeSnapshot represents the relationships between the frames that the robot + * knows about at a particular point in time. For example, with the FrameTreeSnapshot, + * an API client can determine where the "body" is relative to the "vision". \ + * + * To reduce data bandwidth, the FrameTreeSnapshot will typically contain + * a small subset of all known frames. By default, all services MUST + * include "vision", "body", and "odom" frames in the FrameTreeSnapshot, but + * additional frames can also be included. For example, an Image service + * would likely include the frame located at the base of the camera lens + * where the picture was taken. \ + * + * Frame relationships are expressed as edges between "parent" frames and + * "child" frames, with an SE3Pose indicating the pose of the "child" frame + * expressed in the "parent" frame. These edges are included in the edge_map + * field. For example, if frame "hand" is 1m in front of the frame "shoulder", + * then the FrameTreeSnapshot might contain: \ + * edge_map { \ + * key: "hand" \ + * value: { \ + * parent_frame_name: "shoulder" \ + * parent_tform_child: { \ + * position: { \ + * x: 1.0 \ + * y: 0.0 \ + * z: 0.0 \ + * } \ + * } \ + * } \ + * } \ + * + * Frame relationships can be inverted. So, to find where the "shoulder" + * is in relationship the "hand", the parent_tform_child pose in the edge + * above can be inverted: \ + * hand_tform_shoulder = shoulder_tform_hand.inverse() \ + * Frame relationships can also be concatenated. If there is an additional + * edge specifying the pose of the "shoulder" relative to the "body", then + * to find where the "hand" is relative to the "body" do: \ + * body_tform_hand = body_tform_shoulder * shoulder_tform_hand \ + * + * The two properties above reduce data size. Instead of having to send N^2 + * edge_map entries to represent all relationships between N frames, + * only N edge_map entries need to be sent. Clients will need to determine + * the chain of edges to follow to get from one frame to another frae, + * and then do inversion and concatentation to generate the appropriate pose. \ + * + * Note that all FrameTreeSnapshots are expected to be a single rooted tree. + * The syntax for FrameTreeSnapshot could also support graphs with + * cycles, or forests of trees - but clients should treat those as invalid + * representations. \ + */ + + // ParentEdge represents the relationship from a child frame to a parent frame. + message ParentEdge { + // The name of the parent frame. If a frame has no parent (parent_frame_name is empty), + // it is the root of the tree. + string parent_frame_name = 1; + + // Transform representing the pose of the child frame in the parent's frame. + SE3Pose parent_tform_child = 2; + + } + + // child_to_parent_edge_map maps the child frame name to the ParentEdge. + // In aggregate, this forms the tree structure. + map child_to_parent_edge_map = 1; +} + + +// Geometric primitive describing a two-dimensional box. +message Box2 { + Vec2 size = 1; +} + +// Geometric primitive to describe a 2D box in a specific frame. +message Box2WithFrame { + // The box is specified with width (y) and length (x), and the full box is + // fixed at an origin, where it's sides are along the coordinate frame's + // axes. + Box2 box = 1; + // The pose of the axis-aligned box is in 'frame_name'. + string frame_name = 2; + // The transformation of the axis-aligned box into the desired frame + // (specified above). + SE3Pose frame_name_tform_box = 3; +} + +// Geometric primitive describing a three-dimensional box. +message Box3 { + Vec3 size = 1; +} + +// Geometric primitive to describe a 3D box in a specific frame. +message Box3WithFrame { + // The box width (y), length (x), and height (z) are interpreted in, and the + // full box is fixed at an origin, where it's sides are along the coordinate + // frame's axes. + Box3 box = 1; + // The pose of the axis-aligned box is in 'frame_name'. + string frame_name = 2; + // The transformation of the axis-aligned box into the desired frame + // (specified above). + SE3Pose frame_name_tform_box = 3; +} + +// Represents a row-major order matrix of doubles. +message Matrix { + int32 rows = 1; + int32 cols = 2; + repeated double values = 3; +} + +// Represents a row-major order matrix of floats. +message Matrixf { + int32 rows = 1; + int32 cols = 2; + repeated float values = 3; +} + +// Represents a row-major order matrix of int64. +message MatrixInt64 { + int32 rows = 1; + int32 cols = 2; + repeated int64 values = 3; +} + +// Represents a row-major order matrix of int32. +message MatrixInt32 { + int32 rows = 1; + int32 cols = 2; + repeated int32 values = 3; +} + +// Represents a vector of doubles +message Vector { + repeated double values = 1; +} + +// Represents the translation/rotation covariance of an SE3 Pose. +// The 6x6 matrix can be viewed as the covariance among 6 variables: \ +// rx ry rz x y z \ +// rx rxrx rxry rxrz rxx rxy rxz \ +// ry ryrx ryry ryrz ryx ryy ryz \ +// rz rzrx rzry rzrz rzx rzy rzz \ +// x xrx xry xrz xx xy xz \ +// y yrx yry yrz yx yy yz \ +// z zrx zry zrz zx zy zz \ +// where x, y, z are translations in meters, and rx, ry, rz are rotations around +// the x, y and z axes in radians. \ +// The matrix is symmetric, so, for example, xy = yx. \ +message SE3Covariance { +// Row-major order representation of the covariance matrix. +Matrix matrix = 1; +// Variance of the yaw component of the SE3 Pose. +// Warning: DEPRECATED as of 2.1. This should equal cov_rzrz, inside `matrix`. Will be removed +// in 4.0. +double yaw_variance = 2 [deprecated = true]; +// Warning: DEPRECATED as of 2.1. Use 'matrix.' Will be removed in 4.0. +double cov_xx = 3 [deprecated = true]; +// Warning: DEPRECATED as of 2.1. Use 'matrix.' Will be removed in 4.0. +double cov_xy = 4 [deprecated = true]; +// Warning: DEPRECATED as of 2.1. Use 'matrix.' Will be removed in 4.0. +double cov_xz = 5 [deprecated = true]; +// Warning: DEPRECATED as of 2.1. Use 'matrix.' Will be removed in 4.0. +double cov_yx = 6 [deprecated = true]; +// Warning: DEPRECATED as of 2.1. Use 'matrix.' Will be removed in 4.0. +double cov_yy = 7 [deprecated = true]; +// Warning: DEPRECATED as of 2.1. Use 'matrix.' Will be removed in 4.0. +double cov_yz = 8 [deprecated = true]; +// Warning: DEPRECATED as of 2.1. Use 'matrix.' Will be removed in 4.0. +double cov_zx = 9 [deprecated = true]; +// Warning: DEPRECATED as of 2.1. Use 'matrix.' Will be removed in 4.0. +double cov_zy = 10 [deprecated = true]; +// Warning: DEPRECATED as of 2.1. Use 'matrix.' Will be removed in 4.0. +double cov_zz = 11 [deprecated = true]; +} + +// Multi-part, 1D line segments defined by a series of points. +message PolyLine { + repeated bosdyn.api.Vec2 points = 1; +} + +// Polygon in the XY plane. +// May be concave, but should not self-intersect. Vertices can be specified in either +// clockwise or counterclockwise orders. +message Polygon { + repeated Vec2 vertexes = 1; +} + +// Represents a region in the XY plane that consists of a single polygon +// from which polygons representing exclusion areas may be subtracted. +// +// A point is considered to be inside the region if it is inside the inclusion +// polygon and not inside any of the exclusion polygons. +// +// Note that while this can be used to represent a polygon with holes, that +// exclusions are not necessarily holes: An exclusion polygon may not be +// completely inside the inclusion polygon. +message PolygonWithExclusions { + Polygon inclusion = 5; + repeated Polygon exclusions = 6; +} + + +// Represents a circular 2D area. +message Circle { + bosdyn.api.Vec2 center_pt = 1; + double radius = 2; // Dimensions in m from center_pt. +} + +// Represents an area in the XY plane. +message Area { + oneof geometry { + Polygon polygon = 1; + Circle circle = 2; + } +} + +// Represents a volume of space in an unspecified frame. +message Volume { + oneof geometry { + Vec3 box = 1; // Dimensions in m, centered on frame origin. + } +} + +// Represents bounds on a value, such that lower < value < upper. +// If you do not want to specify one side of the bound, set it to +// an appropriately large (or small) number. +message Bounds { + double lower = 1; + double upper = 2; +} + +// A 2D vector of doubles that uses wrapped values so we can tell which elements are set. +message Vec2Value { + google.protobuf.DoubleValue x = 1; + google.protobuf.DoubleValue y = 2; +} + +// A 3D vector of doubles that uses wrapped values so we can tell which elements are set. +message Vec3Value { + google.protobuf.DoubleValue x = 1; + google.protobuf.DoubleValue y = 2; + google.protobuf.DoubleValue z = 3; +} diff --git a/proto2ros_tests/proto/test.proto b/proto2ros_tests/proto/test.proto new file mode 100644 index 0000000..7e22bf2 --- /dev/null +++ b/proto2ros_tests/proto/test.proto @@ -0,0 +1,197 @@ +// Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +syntax = "proto3"; + +package proto2ros_tests; + +option java_outer_classname = "Proto2ROSTests"; + +import "bosdyn/api/geometry.proto"; + +enum Direction { + LEFT = 0; + RIGHT = 1; + FORWARD = 2; + BACKWARD = 3; +} + +// Binary recursive fragment. +message Fragment { + // fragment payload. + bytes payload = 1; + // nested fragments. + repeated Fragment nested = 2; +} + +// Discrete motion request. +message MotionRequest { + // Valid directions for motion. + enum Direction { + LEFT = 0; + RIGHT = 1; + FORWARD = 2; + BACKWARD = 3; + } + // Direction of motion. + Direction direction = 1; + // motion speed. + float speed = 2; +} + +// Empty HTTP message. +/* + * For namespacing purposes only. + */ +message HTTP { + // Supported HTTP methods. + enum Method { + GET = 0; + HEAD = 1; + POST = 2; + PUT = 3; + } + // HTTP request message. + message Request { + // HTTP request method. + Method method = 1; + // HTTP resource URI. + string uri = 2; + // HTTP request body. + bytes body = 3; + } + // HTTP status codes. + enum Status { + UNDEFINED = 0; + OK = 200; + NOT_FOUND = 404; + INTERNAL_SERVER_ERROR = 500; + } + // HTTP response message. + message Response { + // HTTP response status. + Status status = 1; + // HTTP response reason. + string reason = 2; + // HTTP response body. + bytes body = 3; + } +} + +// Any single robot command. +message AnyCommand { + // Walk command. + message Walk { + // Walking distance. + float distance = 1; + // walking speed. + float speed = 2; + } + // Jump command. + message Jump { + // Jump height. + float height = 1; + } + oneof commands { + Walk walk = 1; + Jump jump = 2; + } +} + +// Generic diagnostic message. +message Diagnostic { + enum Severity { + // Informational diagnostic severity. + INFO = 0; + WARN = 1; + FATAL = 2; + } + // Diagnostic severity. + Severity severity = 1; + // Diagnostic reason. + string reason = 2; + // Diagnostic attributes. + map attributes = 3; +} + +// Remote RPA request. +message RemoteExecutionRequest { + // Execution prompt. + string prompt = 1; + // Timeout for request, in nanoseconds + int64 timeout = 2 [deprecated = true]; + // Timeout for request, in seconds. + float timeout_sec = 3; +} + +// Generic error message. +message Error { + // Integer error code. + int32 code = 1; + // error reason. + string reason = 2; +} + +// Remote RPA result. +message RemoteExecutionResult { + // Remote execution error. + message Error { + int32 code = 1; + // May contain backslashes (\). + string reason = 2; + repeated string traceback = 3; + } + bool ok = 1; + Error error = 2; +} + + +// 3D displacement +message Displacement { + bosdyn.api.Vec3 translation = 1; + // Rotation field (TBD) + reserved "rotation"; +} + +// Camera sensor information. +message CameraInfo { + message DistortionModel { + enum Type { + // Uses 5 coefficients. + PLUMB_BOB = 0; + // Uses 4 coefficients. + EQUIDISTANT = 1; + } + Type type = 1; + repeated double coefficients = 2; + } + + uint32 height = 1; + uint32 width = 2; + + // Intrinsic matrix. + Matrix K = 3; + // Rectification matrix. + Matrix R = 4; + // Projection matrix. + Matrix P = 5; + + DistortionModel distortion_model = 6; +} + +message Matrix { + uint32 rows = 1; + uint32 cols = 2; + // Row-major matrix data. + repeated double data = 3; +} + +// Generic map message. +message Map { + message Fragment { + int32 width = 1; + int32 height = 2; + // Fragment grid as a column-major matrix. + bytes grid = 3; + } + map submaps = 1; +} diff --git a/proto2ros_tests/proto2ros_tests/__init__.py b/proto2ros_tests/proto2ros_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/proto2ros_tests/proto2ros_tests/manual_conversions.py b/proto2ros_tests/proto2ros_tests/manual_conversions.py new file mode 100644 index 0000000..d3f33e9 --- /dev/null +++ b/proto2ros_tests/proto2ros_tests/manual_conversions.py @@ -0,0 +1,90 @@ +import bosdyn.api.geometry_pb2 +import geometry_msgs.msg + +from proto2ros.conversions import convert + + +@convert.register(geometry_msgs.msg.Vector3, bosdyn.api.geometry_pb2.Vec3) +def convert_geometry_msgs_vector3_message_to_bosdyn_api_vec3_proto( + ros_msg: geometry_msgs.msg.Vector3, proto_msg: bosdyn.api.geometry_pb2.Vec3 +) -> None: + proto_msg.x = ros_msg.x + proto_msg.y = ros_msg.y + proto_msg.z = ros_msg.z + + +@convert.register(bosdyn.api.geometry_pb2.Vec3, geometry_msgs.msg.Vector3) +def convert_bosdyn_api_vec3_proto_to_geometry_msgs_vector3_message( + proto_msg: bosdyn.api.geometry_pb2.Vec3, ros_msg: geometry_msgs.msg.Vector3 +) -> None: + ros_msg.x = proto_msg.x + ros_msg.y = proto_msg.y + ros_msg.z = proto_msg.z + + +@convert.register(geometry_msgs.msg.Quaternion, bosdyn.api.geometry_pb2.Quaternion) +def convert_geometry_msgs_quaternion_message_to_bosdyn_api_quaternion_proto( + ros_msg: geometry_msgs.msg.Quaternion, proto_msg: bosdyn.api.geometry_pb2.Quaternion +) -> None: + proto_msg.w = ros_msg.w + proto_msg.x = ros_msg.x + proto_msg.y = ros_msg.y + proto_msg.z = ros_msg.z + + +@convert.register(bosdyn.api.geometry_pb2.Quaternion, geometry_msgs.msg.Quaternion) +def convert_bosdyn_api_quaternion_proto_to_geometry_msgs_quaternion_message( + proto_msg: bosdyn.api.geometry_pb2.Quaternion, ros_msg: geometry_msgs.msg.Quaternion +) -> None: + ros_msg.w = proto_msg.w + ros_msg.x = proto_msg.x + ros_msg.y = proto_msg.y + ros_msg.z = proto_msg.z + + +@convert.register(geometry_msgs.msg.Pose, bosdyn.api.geometry_pb2.SE3Pose) +def convert_geometry_msgs_pose_message_to_bosdyn_api_pose_proto( + ros_msg: geometry_msgs.msg.Pose, proto_msg: bosdyn.api.geometry_pb2.SE3Pose +) -> None: + convert_geometry_msgs_vector3_message_to_bosdyn_api_vec3_proto(ros_msg.position, proto_msg.position) + convert_geometry_msgs_quaternion_message_to_bosdyn_api_quaternion_proto(ros_msg.orientation, proto_msg.rotation) + + +@convert.register(bosdyn.api.geometry_pb2.SE3Pose, geometry_msgs.msg.Pose) +def convert_bosdyn_api_pose_proto_to_geometry_msgs_pose_message( + proto_msg: bosdyn.api.geometry_pb2.SE3Pose, ros_msg: geometry_msgs.msg.Pose +) -> None: + convert_bosdyn_api_vec3_proto_to_geometry_msgs_vector3_message(proto_msg.position, ros_msg.position) + convert_bosdyn_api_quaternion_proto_to_geometry_msgs_quaternion_message(proto_msg.rotation, ros_msg.orientation) + + +@convert.register(geometry_msgs.msg.Twist, bosdyn.api.geometry_pb2.SE3Velocity) +def convert_geometry_msgs_twist_message_to_bosdyn_api_se3_velocity_proto( + ros_msg: geometry_msgs.msg.Twist, proto_msg: bosdyn.api.geometry_pb2.SE3Velocity +) -> None: + convert_geometry_msgs_vector3_message_to_bosdyn_api_vec3_proto(ros_msg.linear, proto_msg.linear) + convert_geometry_msgs_vector3_message_to_bosdyn_api_vec3_proto(ros_msg.angular, proto_msg.angular) + + +@convert.register(bosdyn.api.geometry_pb2.SE3Velocity, geometry_msgs.msg.Twist) +def convert_bosdyn_api_se3_velocity_proto_to_geometry_msgs_twist_message( + proto_msg: bosdyn.api.geometry_pb2.SE3Velocity, ros_msg: geometry_msgs.msg.Twist +) -> None: + convert_bosdyn_api_vec3_proto_to_geometry_msgs_vector3_message(proto_msg.linear, ros_msg.linear) + convert_bosdyn_api_vec3_proto_to_geometry_msgs_vector3_message(proto_msg.angular, ros_msg.angular) + + +@convert.register(geometry_msgs.msg.Wrench, bosdyn.api.geometry_pb2.Wrench) +def convert_geometry_msgs_wrench_message_to_bosdyn_api_wrench_proto( + ros_msg: geometry_msgs.msg.Wrench, proto_msg: bosdyn.api.geometry_pb2.Wrench +) -> None: + convert_geometry_msgs_vector3_message_to_bosdyn_api_vec3_proto(ros_msg.force, proto_msg.force) + convert_geometry_msgs_vector3_message_to_bosdyn_api_vec3_proto(ros_msg.torque, proto_msg.torque) + + +@convert.register(bosdyn.api.geometry_pb2.Wrench, geometry_msgs.msg.Wrench) +def convert_bosdyn_api_wrench_proto_to_geometry_msgs_wrench_messagey( + proto_msg: bosdyn.api.geometry_pb2.Wrench, ros_msg: geometry_msgs.msg.Wrench +) -> None: + convert_bosdyn_api_vec3_proto_to_geometry_msgs_vector3_message(proto_msg.force, ros_msg.force) + convert_bosdyn_api_vec3_proto_to_geometry_msgs_vector3_message(proto_msg.torque, ros_msg.torque) diff --git a/proto2ros_tests/test/generated/AnyCommand.msg b/proto2ros_tests/test/generated/AnyCommand.msg new file mode 100644 index 0000000..27ab3ff --- /dev/null +++ b/proto2ros_tests/test/generated/AnyCommand.msg @@ -0,0 +1,3 @@ +# Any single robot command. + +proto2ros_tests/AnyCommandOneOfCommands commands diff --git a/proto2ros_tests/test/generated/AnyCommandJump.msg b/proto2ros_tests/test/generated/AnyCommandJump.msg new file mode 100644 index 0000000..0823e68 --- /dev/null +++ b/proto2ros_tests/test/generated/AnyCommandJump.msg @@ -0,0 +1,4 @@ +# Jump command. + +# Jump height. +float32 height diff --git a/proto2ros_tests/test/generated/AnyCommandOneOfCommands.msg b/proto2ros_tests/test/generated/AnyCommandOneOfCommands.msg new file mode 100644 index 0000000..7239d50 --- /dev/null +++ b/proto2ros_tests/test/generated/AnyCommandOneOfCommands.msg @@ -0,0 +1,9 @@ + +int8 COMMANDS_NOT_SET=0 +int8 COMMANDS_WALK_SET=1 +int8 COMMANDS_JUMP_SET=2 + +proto2ros_tests/AnyCommandWalk walk +proto2ros_tests/AnyCommandJump jump +int8 commands_choice # deprecated +int8 which diff --git a/proto2ros_tests/test/generated/AnyCommandWalk.msg b/proto2ros_tests/test/generated/AnyCommandWalk.msg new file mode 100644 index 0000000..64c7448 --- /dev/null +++ b/proto2ros_tests/test/generated/AnyCommandWalk.msg @@ -0,0 +1,6 @@ +# Walk command. + +# Walking distance. +float32 distance +# walking speed. +float32 speed diff --git a/proto2ros_tests/test/generated/CameraInfo.msg b/proto2ros_tests/test/generated/CameraInfo.msg new file mode 100644 index 0000000..855b592 --- /dev/null +++ b/proto2ros_tests/test/generated/CameraInfo.msg @@ -0,0 +1,17 @@ +# Camera sensor information. + +uint8 K_FIELD_SET=4 +uint8 R_FIELD_SET=8 +uint8 P_FIELD_SET=16 +uint8 DISTORTION_MODEL_FIELD_SET=32 + +uint32 height +uint32 width +# Intrinsic matrix. +proto2ros_tests/Matrix k +# Rectification matrix. +proto2ros_tests/Matrix r +# Projection matrix. +proto2ros_tests/Matrix p +proto2ros_tests/CameraInfoDistortionModel distortion_model +uint8 has_field 255 diff --git a/proto2ros_tests/test/generated/CameraInfoDistortionModel.msg b/proto2ros_tests/test/generated/CameraInfoDistortionModel.msg new file mode 100644 index 0000000..c42c3e0 --- /dev/null +++ b/proto2ros_tests/test/generated/CameraInfoDistortionModel.msg @@ -0,0 +1,3 @@ + +proto2ros_tests/CameraInfoDistortionModelType type +float64[] coefficients diff --git a/proto2ros_tests/test/generated/CameraInfoDistortionModelType.msg b/proto2ros_tests/test/generated/CameraInfoDistortionModelType.msg new file mode 100644 index 0000000..0cd9cf0 --- /dev/null +++ b/proto2ros_tests/test/generated/CameraInfoDistortionModelType.msg @@ -0,0 +1,7 @@ + +# Uses 5 coefficients. +int32 PLUMB_BOB=0 +# Uses 4 coefficients. +int32 EQUIDISTANT=1 + +int32 value diff --git a/proto2ros_tests/test/generated/Diagnostic.msg b/proto2ros_tests/test/generated/Diagnostic.msg new file mode 100644 index 0000000..844f023 --- /dev/null +++ b/proto2ros_tests/test/generated/Diagnostic.msg @@ -0,0 +1,8 @@ +# Generic diagnostic message. + +# Diagnostic severity. +proto2ros_tests/DiagnosticSeverity severity +# Diagnostic reason. +string reason +# Diagnostic attributes. +proto2ros_tests/DiagnosticAttributesEntry[] attributes diff --git a/proto2ros_tests/test/generated/DiagnosticAttributesEntry.msg b/proto2ros_tests/test/generated/DiagnosticAttributesEntry.msg new file mode 100644 index 0000000..49e0a1d --- /dev/null +++ b/proto2ros_tests/test/generated/DiagnosticAttributesEntry.msg @@ -0,0 +1,3 @@ + +string key +string value diff --git a/proto2ros_tests/test/generated/DiagnosticSeverity.msg b/proto2ros_tests/test/generated/DiagnosticSeverity.msg new file mode 100644 index 0000000..a3b5b2d --- /dev/null +++ b/proto2ros_tests/test/generated/DiagnosticSeverity.msg @@ -0,0 +1,7 @@ + +# Informational diagnostic severity. +int32 INFO=0 +int32 WARN=1 +int32 FATAL=2 + +int32 value diff --git a/proto2ros_tests/test/generated/Direction.msg b/proto2ros_tests/test/generated/Direction.msg new file mode 100644 index 0000000..30ff0d1 --- /dev/null +++ b/proto2ros_tests/test/generated/Direction.msg @@ -0,0 +1,7 @@ + +int32 LEFT=0 +int32 RIGHT=1 +int32 FORWARD=2 +int32 BACKWARD=3 + +int32 value diff --git a/proto2ros_tests/test/generated/Displacement.msg b/proto2ros_tests/test/generated/Displacement.msg new file mode 100644 index 0000000..44d47ea --- /dev/null +++ b/proto2ros_tests/test/generated/Displacement.msg @@ -0,0 +1,6 @@ +# 3D displacement + +uint8 TRANSLATION_FIELD_SET=1 + +geometry_msgs/Vector3 translation +uint8 has_field 255 diff --git a/proto2ros_tests/test/generated/Error.msg b/proto2ros_tests/test/generated/Error.msg new file mode 100644 index 0000000..1eca188 --- /dev/null +++ b/proto2ros_tests/test/generated/Error.msg @@ -0,0 +1,6 @@ +# Generic error message. + +# Integer error code. +int32 code +# error reason. +string reason diff --git a/proto2ros_tests/test/generated/Fragment.msg b/proto2ros_tests/test/generated/Fragment.msg new file mode 100644 index 0000000..fbb50e3 --- /dev/null +++ b/proto2ros_tests/test/generated/Fragment.msg @@ -0,0 +1,6 @@ +# Binary recursive fragment. + +# fragment payload. +uint8[] payload +# nested fragments. +proto2ros/Any[] nested # is proto2ros_tests/Fragment[] (type-erased) diff --git a/proto2ros_tests/test/generated/HTTP.msg b/proto2ros_tests/test/generated/HTTP.msg new file mode 100644 index 0000000..e8c00fa --- /dev/null +++ b/proto2ros_tests/test/generated/HTTP.msg @@ -0,0 +1,3 @@ +# Empty HTTP message. +# +# For namespacing purposes only. diff --git a/proto2ros_tests/test/generated/HTTPMethod.msg b/proto2ros_tests/test/generated/HTTPMethod.msg new file mode 100644 index 0000000..e407ae2 --- /dev/null +++ b/proto2ros_tests/test/generated/HTTPMethod.msg @@ -0,0 +1,8 @@ +# Supported HTTP methods. + +int32 GET=0 +int32 HEAD=1 +int32 POST=2 +int32 PUT=3 + +int32 value diff --git a/proto2ros_tests/test/generated/HTTPRequest.msg b/proto2ros_tests/test/generated/HTTPRequest.msg new file mode 100644 index 0000000..beb6fa1 --- /dev/null +++ b/proto2ros_tests/test/generated/HTTPRequest.msg @@ -0,0 +1,8 @@ +# HTTP request message. + +# HTTP request method. +proto2ros_tests/HTTPMethod method +# HTTP resource URI. +string uri +# HTTP request body. +uint8[] body diff --git a/proto2ros_tests/test/generated/HTTPResponse.msg b/proto2ros_tests/test/generated/HTTPResponse.msg new file mode 100644 index 0000000..7ab8e70 --- /dev/null +++ b/proto2ros_tests/test/generated/HTTPResponse.msg @@ -0,0 +1,8 @@ +# HTTP response message. + +# HTTP response status. +proto2ros_tests/HTTPStatus status +# HTTP response reason. +string reason +# HTTP response body. +uint8[] body diff --git a/proto2ros_tests/test/generated/HTTPStatus.msg b/proto2ros_tests/test/generated/HTTPStatus.msg new file mode 100644 index 0000000..8f7755b --- /dev/null +++ b/proto2ros_tests/test/generated/HTTPStatus.msg @@ -0,0 +1,8 @@ +# HTTP status codes. + +int32 UNDEFINED=0 +int32 OK=200 +int32 NOT_FOUND=404 +int32 INTERNAL_SERVER_ERROR=500 + +int32 value diff --git a/proto2ros_tests/test/generated/Map.msg b/proto2ros_tests/test/generated/Map.msg new file mode 100644 index 0000000..90bfa43 --- /dev/null +++ b/proto2ros_tests/test/generated/Map.msg @@ -0,0 +1,3 @@ +# Generic map message. + +proto2ros_tests/MapSubmapsEntry[] submaps diff --git a/proto2ros_tests/test/generated/MapFragment.msg b/proto2ros_tests/test/generated/MapFragment.msg new file mode 100644 index 0000000..a7fdd7a --- /dev/null +++ b/proto2ros_tests/test/generated/MapFragment.msg @@ -0,0 +1,5 @@ + +int32 width +int32 height +# Fragment grid as a column-major matrix. +uint8[] grid diff --git a/proto2ros_tests/test/generated/MapSubmapsEntry.msg b/proto2ros_tests/test/generated/MapSubmapsEntry.msg new file mode 100644 index 0000000..d92cac4 --- /dev/null +++ b/proto2ros_tests/test/generated/MapSubmapsEntry.msg @@ -0,0 +1,3 @@ + +int32 key +proto2ros_tests/MapFragment value diff --git a/proto2ros_tests/test/generated/Matrix.msg b/proto2ros_tests/test/generated/Matrix.msg new file mode 100644 index 0000000..79ed701 --- /dev/null +++ b/proto2ros_tests/test/generated/Matrix.msg @@ -0,0 +1,5 @@ + +uint32 rows +uint32 cols +# Row-major matrix data. +float64[] data diff --git a/proto2ros_tests/test/generated/MotionRequest.msg b/proto2ros_tests/test/generated/MotionRequest.msg new file mode 100644 index 0000000..a4bab33 --- /dev/null +++ b/proto2ros_tests/test/generated/MotionRequest.msg @@ -0,0 +1,6 @@ +# Discrete motion request. + +# Direction of motion. +proto2ros_tests/MotionRequestDirection direction +# motion speed. +float32 speed diff --git a/proto2ros_tests/test/generated/MotionRequestDirection.msg b/proto2ros_tests/test/generated/MotionRequestDirection.msg new file mode 100644 index 0000000..664fc93 --- /dev/null +++ b/proto2ros_tests/test/generated/MotionRequestDirection.msg @@ -0,0 +1,8 @@ +# Valid directions for motion. + +int32 LEFT=0 +int32 RIGHT=1 +int32 FORWARD=2 +int32 BACKWARD=3 + +int32 value diff --git a/proto2ros_tests/test/generated/RemoteExecutionRequest.msg b/proto2ros_tests/test/generated/RemoteExecutionRequest.msg new file mode 100644 index 0000000..6dff7ca --- /dev/null +++ b/proto2ros_tests/test/generated/RemoteExecutionRequest.msg @@ -0,0 +1,8 @@ +# Remote RPA request. + +# Execution prompt. +string prompt +# Timeout for request, in nanoseconds +int64 timeout # deprecated +# Timeout for request, in seconds. +float32 timeout_sec diff --git a/proto2ros_tests/test/generated/RemoteExecutionResult.msg b/proto2ros_tests/test/generated/RemoteExecutionResult.msg new file mode 100644 index 0000000..4039b7d --- /dev/null +++ b/proto2ros_tests/test/generated/RemoteExecutionResult.msg @@ -0,0 +1,7 @@ +# Remote RPA result. + +uint8 ERROR_FIELD_SET=2 + +bool ok +proto2ros_tests/RemoteExecutionResultError error +uint8 has_field 255 diff --git a/proto2ros_tests/test/generated/RemoteExecutionResultError.msg b/proto2ros_tests/test/generated/RemoteExecutionResultError.msg new file mode 100644 index 0000000..a590526 --- /dev/null +++ b/proto2ros_tests/test/generated/RemoteExecutionResultError.msg @@ -0,0 +1,6 @@ +# Remote execution error. + +int32 code +# May contain backslashes (). +string reason +string[] traceback diff --git a/proto2ros_tests/test/test_proto2ros.py b/proto2ros_tests/test/test_proto2ros.py new file mode 100644 index 0000000..706a3ab --- /dev/null +++ b/proto2ros_tests/test/test_proto2ros.py @@ -0,0 +1,241 @@ +# Copyright (c) 2023 Boston Dynamics AI Institute LLC. All rights reserved. + +import filecmp +import os +import pathlib + +import test_pb2 + +import proto2ros_tests.msg +from proto2ros_tests.conversions import convert + + +def test_message_generation() -> None: + cmake_binary_dir = pathlib.Path(os.environ["CMAKE_BINARY_DIR"]) + gen_msg_dir = cmake_binary_dir / "proto2ros_generate" / "proto2ros_tests" / "msg" + ref_msg_dir = pathlib.Path(__file__).resolve().parent / "generated" + _, mismatch, errors = filecmp.cmpfiles(gen_msg_dir, ref_msg_dir, os.listdir(ref_msg_dir)) + assert not mismatch, mismatch + assert not errors, errors + + +def test_recursive_messages() -> None: + proto_fragment = test_pb2.Fragment() + proto_fragment.payload = b"important data" + proto_subfragment = test_pb2.Fragment() + proto_subfragment.payload = b"important addendum" + proto_fragment.nested.append(proto_subfragment) + ros_fragment = proto2ros_tests.msg.Fragment() + convert(proto_fragment, ros_fragment) + assert ros_fragment.payload.tobytes() == proto_fragment.payload + assert len(ros_fragment.nested) == len(proto_fragment.nested) + other_proto_fragment = test_pb2.Fragment() + convert(ros_fragment, other_proto_fragment) + assert other_proto_fragment.payload == proto_fragment.payload + assert len(other_proto_fragment.nested) == len(proto_fragment.nested) + other_proto_subfragment = other_proto_fragment.nested[0] + assert other_proto_subfragment.payload == proto_subfragment.payload + + +def test_messages_with_enums() -> None: + proto_motion_request = test_pb2.MotionRequest() + proto_motion_request.direction = test_pb2.MotionRequest.Direction.FORWARD + proto_motion_request.speed = 1.0 + ros_motion_request = proto2ros_tests.msg.MotionRequest() + convert(proto_motion_request, ros_motion_request) + assert ros_motion_request.direction.value == proto_motion_request.direction + assert ros_motion_request.speed == proto_motion_request.speed + other_proto_motion_request = test_pb2.MotionRequest() + convert(ros_motion_request, other_proto_motion_request) + assert other_proto_motion_request.direction == proto_motion_request.direction + assert other_proto_motion_request.speed == proto_motion_request.speed + + +def test_messages_with_nested_enums() -> None: + proto_http_request = test_pb2.HTTP.Request() + proto_http_request.method = test_pb2.HTTP.Method.PUT + proto_http_request.uri = "https://proto2ros.xyz/post" + ros_http_request = proto2ros_tests.msg.HTTPRequest() + convert(proto_http_request, ros_http_request) + assert ros_http_request.method.value == test_pb2.HTTP.Method.PUT + assert ros_http_request.uri == proto_http_request.uri + other_proto_http_request = test_pb2.HTTP.Request() + convert(ros_http_request, other_proto_http_request) + assert other_proto_http_request.method == proto_http_request.method + assert other_proto_http_request.uri == proto_http_request.uri + + +def test_one_of_messages() -> None: + proto_any_command = test_pb2.AnyCommand() + proto_any_command.walk.distance = 1.0 + proto_any_command.walk.speed = 1.0 + + ros_any_command = proto2ros_tests.msg.AnyCommand() + convert(proto_any_command, ros_any_command) + walk_set = proto2ros_tests.msg.AnyCommandOneOfCommands.COMMANDS_WALK_SET + assert ros_any_command.commands.commands_choice == walk_set + assert ros_any_command.commands.walk.distance == proto_any_command.walk.distance + assert ros_any_command.commands.walk.speed == proto_any_command.walk.speed + + other_proto_any_command = test_pb2.AnyCommand() + convert(ros_any_command, other_proto_any_command) + assert other_proto_any_command.WhichOneof("commands") == "walk" + assert other_proto_any_command.walk.distance == proto_any_command.walk.distance + assert other_proto_any_command.walk.speed == proto_any_command.walk.speed + + +def test_messages_with_map_field() -> None: + proto_diagnostic = test_pb2.Diagnostic() + proto_diagnostic.severity = test_pb2.Diagnostic.Severity.FATAL + proto_diagnostic.reason = "Localization estimate diverged, cannot recover" + proto_diagnostic.attributes["origin"] = "localization subsystem" + + ros_diagnostic = proto2ros_tests.msg.Diagnostic() + convert(proto_diagnostic, ros_diagnostic) + assert ros_diagnostic.severity.value == proto_diagnostic.severity + assert ros_diagnostic.reason == proto_diagnostic.reason + assert len(ros_diagnostic.attributes) == 1 + assert ros_diagnostic.attributes[0].key == "origin" + assert ros_diagnostic.attributes[0].value == "localization subsystem" + + other_proto_diagnostic = test_pb2.Diagnostic() + convert(ros_diagnostic, other_proto_diagnostic) + assert other_proto_diagnostic.severity == proto_diagnostic.severity + assert other_proto_diagnostic.reason == proto_diagnostic.reason + assert other_proto_diagnostic.attributes == proto_diagnostic.attributes + + +def test_messages_with_deprecated_fields() -> None: + proto_remote_execution_request = test_pb2.RemoteExecutionRequest() + proto_remote_execution_request.prompt = "grab bike seat" + proto_remote_execution_request.timeout = 2 + proto_remote_execution_request.timeout_sec = 10.0 + + ros_remote_execution_request = proto2ros_tests.msg.RemoteExecutionRequest() + convert(proto_remote_execution_request, ros_remote_execution_request) + assert ros_remote_execution_request.prompt == proto_remote_execution_request.prompt + assert ros_remote_execution_request.timeout == proto_remote_execution_request.timeout + assert ros_remote_execution_request.timeout_sec == proto_remote_execution_request.timeout_sec + + other_proto_remote_execution_request = test_pb2.RemoteExecutionRequest() + convert(ros_remote_execution_request, other_proto_remote_execution_request) + assert other_proto_remote_execution_request.prompt == proto_remote_execution_request.prompt + assert other_proto_remote_execution_request.timeout == proto_remote_execution_request.timeout + assert other_proto_remote_execution_request.timeout_sec == proto_remote_execution_request.timeout_sec + + +def test_redefined_messages() -> None: + proto_remote_execution_result = test_pb2.RemoteExecutionResult() + proto_remote_execution_result.ok = False + proto_remote_execution_result.error.code = 255 + proto_remote_execution_result.error.reason = "interrupted" + proto_remote_execution_result.error.traceback.append("") + + ros_remote_execution_result = proto2ros_tests.msg.RemoteExecutionResult() + convert(proto_remote_execution_result, ros_remote_execution_result) + assert ros_remote_execution_result.ok == proto_remote_execution_result.ok + assert ros_remote_execution_result.error.code == proto_remote_execution_result.error.code + assert ros_remote_execution_result.error.reason == proto_remote_execution_result.error.reason + assert len(ros_remote_execution_result.error.traceback) > 0 + assert ros_remote_execution_result.error.traceback[0] == proto_remote_execution_result.error.traceback[0] + + other_proto_remote_execution_result = test_pb2.RemoteExecutionResult() + convert(ros_remote_execution_result, other_proto_remote_execution_result) + assert other_proto_remote_execution_result.ok == proto_remote_execution_result.ok + assert other_proto_remote_execution_result.error.code == proto_remote_execution_result.error.code + assert other_proto_remote_execution_result.error.reason == proto_remote_execution_result.error.reason + assert other_proto_remote_execution_result.error.traceback == proto_remote_execution_result.error.traceback + + +def test_messages_with_reserved_fields() -> None: + proto_displacement = test_pb2.Displacement() + proto_displacement.translation.x = 1.0 + proto_displacement.translation.y = 2.0 + proto_displacement.translation.z = 3.0 + + ros_displacement = proto2ros_tests.msg.Displacement() + convert(proto_displacement, ros_displacement) + assert ros_displacement.translation.x == proto_displacement.translation.x + assert ros_displacement.translation.y == proto_displacement.translation.y + assert ros_displacement.translation.z == proto_displacement.translation.z + assert not hasattr(ros_displacement, "rotation") + + other_proto_displacement = test_pb2.Displacement() + convert(ros_displacement, other_proto_displacement) + assert other_proto_displacement.translation.x == proto_displacement.translation.x + assert other_proto_displacement.translation.y == proto_displacement.translation.y + assert other_proto_displacement.translation.z == proto_displacement.translation.z + + +def test_message_forwarding() -> None: + proto_camera_info = test_pb2.CameraInfo() + proto_camera_info.height = 720 + proto_camera_info.width = 1280 + proto_camera_info.K.rows = 3 + proto_camera_info.K.cols = 3 + proto_camera_info.K.data[:] = [2000, 0, 800, 0, 2000, 800, 0, 0, 1] + proto_camera_info.R.rows = 3 + proto_camera_info.R.cols = 3 + proto_camera_info.R.data[:] = [1, 0, 0, 0, 1, 0, 0, 0, 1] + proto_camera_info.P.rows = 3 + proto_camera_info.P.cols = 4 + proto_camera_info.P.data[:] = [2000, 0, 800, 0, 0, 2000, 800, 0, 0, 0, 0, 1] + proto_camera_info.distortion_model.type = test_pb2.CameraInfo.DistortionModel.Type.PLUMB_BOB + proto_camera_info.distortion_model.coefficients[:] = [-0.2, -0.4, -0.0001, -0.0001, 0] + + ros_camera_info = proto2ros_tests.msg.CameraInfo() + convert(proto_camera_info, ros_camera_info) + assert ros_camera_info.height == proto_camera_info.height + assert ros_camera_info.width == proto_camera_info.width + assert ros_camera_info.k.rows == proto_camera_info.K.rows + assert ros_camera_info.k.cols == proto_camera_info.K.cols + assert list(ros_camera_info.k.data) == proto_camera_info.K.data + assert ros_camera_info.r.rows == proto_camera_info.R.rows + assert ros_camera_info.r.cols == proto_camera_info.R.cols + assert list(ros_camera_info.r.data) == proto_camera_info.R.data + assert ros_camera_info.p.rows == proto_camera_info.P.rows + assert ros_camera_info.p.cols == proto_camera_info.P.cols + assert list(ros_camera_info.p.data) == proto_camera_info.P.data + assert ros_camera_info.distortion_model.type.value == proto_camera_info.distortion_model.type + assert list(ros_camera_info.distortion_model.coefficients) == proto_camera_info.distortion_model.coefficients + + other_proto_camera_info = test_pb2.CameraInfo() + convert(ros_camera_info, other_proto_camera_info) + assert other_proto_camera_info.height == proto_camera_info.height + assert other_proto_camera_info.width == proto_camera_info.width + assert other_proto_camera_info.K.rows == proto_camera_info.K.rows + assert other_proto_camera_info.K.cols == proto_camera_info.K.cols + assert other_proto_camera_info.K.data == proto_camera_info.K.data + assert other_proto_camera_info.R.rows == proto_camera_info.R.rows + assert other_proto_camera_info.R.cols == proto_camera_info.R.cols + assert other_proto_camera_info.R.data == proto_camera_info.R.data + assert other_proto_camera_info.P.rows == proto_camera_info.P.rows + assert other_proto_camera_info.P.cols == proto_camera_info.P.cols + assert other_proto_camera_info.P.data == proto_camera_info.P.data + assert other_proto_camera_info.distortion_model.type == proto_camera_info.distortion_model.type + assert other_proto_camera_info.distortion_model.coefficients == proto_camera_info.distortion_model.coefficients + + +def test_messages_with_submessage_map_field() -> None: + proto_map = test_pb2.Map() + proto_fragment = proto_map.submaps[13] + proto_fragment.height = 20 + proto_fragment.width = 20 + proto_fragment.grid = b"\x00" * 20 * 20 + + ros_map = proto2ros_tests.msg.Map() + convert(proto_map, ros_map) + assert len(ros_map.submaps) == 1 + assert ros_map.submaps[0].key == 13 + ros_fragment = ros_map.submaps[0].value + assert ros_fragment.height == proto_fragment.height + assert ros_fragment.width == proto_fragment.width + assert ros_fragment.grid.tobytes() == proto_fragment.grid + + other_proto_map = test_pb2.Map() + convert(ros_map, other_proto_map) + assert len(other_proto_map.submaps) + other_proto_fragment = other_proto_map.submaps[13] + assert other_proto_fragment.height == proto_fragment.height + assert other_proto_fragment.width == proto_fragment.width + assert other_proto_fragment.grid == proto_fragment.grid