Продолжаем разбираться с тем, как научить ROS2 понимать ваш язык программирования. В прошлый раз мы рассмотрели создание и запуск минимальной программы, теперь поговорим про работу с сообщениями. Свою библиотеку я разрабатывал для Lua, поэтому далее в примерах будет встречаться упоминание этого языка.

Обмен данными играет в ROS2 ключевую роль. К счастью, практически все задачи, связанные с передачей и приемом сообщений берут на себя библиотеки rcl и rmw, нам "всего лишь" необходимо обеспечить возможность их создания и обработки.

Структура сообщения в ROS2 описывается в файле с расширением msg или idl. При сборке пакета выполняются следующие действия:

  • генерация исходного кода на C

  • генерация исходного кода для других языков (C++, Python), который ссылается на C код

  • компиляция динамических библиотек, настройка путей

Рассмотрим подробнее, как эта генерация устроена.

Представление сообщений в C

Прежде чем переходить к другим языкам полезно посмотреть, какую структуру имеют сообщения на C. Вообще, если ваш язык программирования предоставляет возможность взаимодействовать с полями C структур напрямую, вам очень повезло. В противном случае, придётся писать функции для доступа к данным.

Для каждого сообщения генератор C кода формирует файлы нескольких видов. Непосредственно структура сообщений описана в файле тип__struct.h. В тип__functions.c хранятся функции для инициализации, освобождения, копирования, проверки равенства, а в тип__description.c - детальная информация о структуре сообщений.

Поля C структуры расположены в том же порядке, в каком они описаны в файле msg. Для элементарных типов (таких как uint8, float64, bool) используется соответствующий тип из C. В случае объекта формируется отдельная структура, которая затем добавляется в сообщение. Если поле представляет собой статический массив, т.е. число элементов известно заранее, аналогичный массив закладывается и в C структуру. Динамический массив описывается указателем на выделяемую память, текущим числом элементов и размером выделенной памяти. Для примитивных типов и строк динамические массивы используются повсеместно, поэтому они заранее определены, и генератор просто добавляет в хэдеры библиотеку rosidl_runtime_c.

Генератор сообщений

Генерацию сообщений выполняет отдельный ROS2 пакет, написанный на Python. Он включает в себя шаблоны для генерации кода, исполняемые и библиотечные файлы, а также CMake скрипты. Обычно данный пакет содержит следующие папки.

Папка resource

Здесь лежат шаблоны для функций сериализации и десериализации сообщений под конкретный язык программирования. Они имеют расширение em и синтаксически являются макросами над языком Python, позволяющими комбинироавть сырой текст с исполняемым кодом. Можно разделить эти макросы на 4 типа: для управления генерацией кода (@[ ]), определения промежуточных переменных (@{ }), подстановки значения в генерируемый текст (@( )) и комментариев (@#).

@# это строчный комментарий

@# генерация в цикле или по условию
@[инициализация блока]@
текст программы
@[  опциональное промежуточное условие, со смещением]@
альтернативный текст программы
@[конец блока]@

@# объявление переменных, функций, прочий код на Python
@{
var = 42
}@

продолжение программы, значение var равно @(var)

Основная логика преобразования сообщений определена в файле msg.c.em (название может быть произвольным), который служит для генерации C кода. Здесь можно реализовать все необходимые функции для работы с сообщениями, но прежде всего необходимы методы сериализации и десериализации. Они используют следующий шаблон.

@# загрузка зависимостей
@{
from rosidl_generator_lua import NUMERIC_LUA_TYPES, sequence_metatable, make_prefix
from rosidl_parser.definition import EMPTY_STRUCTURE_REQUIRED_MEMBER_NAME
}@

@# формирование имени функции
@[for member in message.structure.members]@
@[  if len(message.structure.members) == 1 and member.name == EMPTY_STRUCTURE_REQUIRED_MEMBER_NAME]@
@[    continue]@
@[  end if]@
@{
msg_prefix = make_prefix(message)
setter_ = '_'.join((msg_prefix, '_set', member.name))
}@

static int @(setter_) (lua_State* L) {

  // подготовительные операции ...

@# генерация наименования типа
@{
type_ = member.type
if isinstance(type_, AbstractNestedType):
    type_ = type_.value_type
}@

  // значение считывается из поля с именем
  // @(member.name)

@#  комплексный тип данных
@[  if isinstance(type_, NamespacedType)]@
@#    массив
@[    if isinstance(member.type, AbstractNestedType)]@
@#      динамический массив
@[      if isinstance(member.type, AbstractSequence)]@

  // копирование данных в динамический массив

@#      статический массив
@[      else]@

  // копирование данных в массив

@[      end if]@

@#    объект
@[    else]@

  // копирование объекта

@[    end if]@

@#  последовательность примитивных типов
@[  elif isinstance(member.type, AbstractNestedType)]@
@#    динамический массив
@[    if isinstance(member.type, AbstractSequence)]@

  // копирование данных в динамический массив

@#    статический массив
@[    else]@

  // копирование данных в массив

@[    end if]@

@#  литерал
@[  elif isinstance(member.type, BasicType) and member.type.typename == 'char']@

  // копирование одиночного символа

@#  логическая переменная
@[  elif isinstance(member.type, BasicType) and member.type.typename == 'boolean']@

  // копирование логической переменной

@#  число
@[  elif isinstance(member.type, BasicType) and member.type.typename in NUMERIC_LUA_TYPES]@
@{
type_dict = NUMERIC_LUA_TYPES[member.type.typename]
}@
@#    проверка беззнакового числа
@[    if member.type.typename.startswith('u') ]@

  // проверка диапазона беззнаковых чисел

@[    else]@

  // проверка диапазона с учетом знака

@[    end if]@

   // копирование значения

@#  строка в 8-битной кодировке
@[  elif isinstance(member.type, AbstractString)]@

  // копирование строки

@#  строка в 16-битной кодировке
@[  elif isinstance(member.type, AbstractWString)]@

  // копирование строки

@[  else]@
@# здесь мы не должны оказаться
@{
assert False, ("unknown type " + member.type.typename)
}@
@[  end if]@
  return 0;
}
@[end for]@

Список полей сообщения определен в переменной message.structure.members. Для каждого поля шаблон проверяет тип переменной, а также является ли она примитивом, объектом или массивом. В последнем случае дополнительно проверяется тип массива (статический или динамический), и какие элементы в нём содержатся (примитивы или объекты). Исходя из этого строится логика преобразования в C и обратно.

Предыдущий шаблон определяет структуру кода, который должен быть записан в файл. Генерацией файлов для заданного пакета управляет шаблон idl.c.em. Он итеративно вызывает функцию TEMPLATE для каждого найденного сообщения.

@{
from rosidl_parser.definition import Message
include_directives = set()
}@
@[for message in content.get_elements_of_type(Message)]@
@{
TEMPLATE(
    'msg.c.em',
    package_name=package_name, interface_path=interface_path,
    message=message, include_directives=include_directives)
}@
@[end for]@

Для ROS2 сервисов генерируются 2 сообщения, соответствующие полям request_message и response_message. В случае action сервисов число сообщений увеличивается до 8 (поля goalresultfeedbacksend_goal_service.request_messagesend_goal_message.response_messageget_result_service.request_messageget_result_service.response_messageaction.feedback_message).

В папке resource можно найти и другие em файлы. Они служат для оборачивания сгенерированных сообщений в одну или несколько динамических библиотек.

Папка rosidl_generator

Скрипт init.py определяет функцию для запуска генерации сообщений, а также вспомогательный функционал, используемый внутри генератора.

from rosidl_cmake import expand_template, generate_files
from rosidl_cmake import get_newest_modification_time, read_generator_arguments

from rosidl_parser.definition import IdlContent, IdlLocator
from rosidl_parser.definition import Message, Service, Action
from rosidl_parser.parser import parse_idl_file

# переменные и функции для обработки числовых полей сообщений
NUMERIC_LUA_TYPES = {
    'float': {'min': 'FLT_MIN', 'max': 'FLT_MAX', 'var': 'lua_Number', 'fn': 'luaL_checknumber',
              'ifn': 'lua_pushnumber', 'ctype': 'float'},
    # etc.
}

# основная функция для генерации C кода библиотеки
def generate_lua(generator_arguments_file, typesupport_impls):
    mapping = {'idl.c.em': '%s.c'}
    generated_files = generate_files(generator_arguments_file, mapping)

    args = read_generator_arguments(generator_arguments_file)
    template_dir = args['template_dir']

    # разбор idl файлов
    modules = {}
    idl_content = IdlContent()
    for idl_tuple in args.get('idl_tuples', []):
        idl_parts = idl_tuple.rsplit(':', 1)
        idl_rel_path = pathlib.Path(idl_parts[1])
        idl_stems = modules.setdefault(str(idl_rel_path.parent), set())
        idl_stems.add(idl_rel_path.stem)

        locator = IdlLocator(*idl_parts)
        idl_file = parse_idl_file(locator)
        idl_content.elements += idl_file.content.elements

    # разделяем по типам в соответствии с реализованными шаблонами
    obj_list = [
        ('msg', idl_content.get_elements_of_type(Message)),
        ('srv', idl_content.get_elements_of_type(Service)),
        ('action', idl_content.get_elements_of_type(Action)),
    ]

    # формирование файлов библиотеки
    latest_target_timestamp = get_newest_modification_time(args['target_dependencies'])
    for msg_type, idl_group in obj_list:
        template_file = msg_type + '_lib.c.em'
        out_name = msg_type + '_lib.c'
        package_name = args['package_name']
        data = {
            'package_name': args['package_name'],
            'content': idl_group,
        }
        generated_file = os.path.join(
            args['output_dir'], msg_type, out_name)
        template = os.path.join(template_dir, template_file)
        # развертывание шаблона
        expand_template(
            template, data, generated_file,
            minimum_timestamp=latest_target_timestamp)
        generated_files.append(generated_file)

    return generated_files

# здесь можно разместить вспомогательные функции для шаблонов

Аргументом функции является JSON файл с параметрами пакета и списком сообщений. В данном примере для какждого типа (Message, Action, Service) определен свой em шаблон. Генератор проходит по списку сообщений, определяет их тип и генерирует соответствующий набор файлов динамической библиотеки.

Папка bin

В данной папке находится Python скрипт, который вызывает написанную выше функцию, передавая ей аргументы командной строки: путь к файлу с агрументами генерации и список типов сообщений.

Папка cmake

Для того чтобы генерация была запущена на этапе сборки ROS2 окружения, нужно настроить CMake. Основную работу выполняет скрипт rosidl_generator_lua_generate_interfaces.cmake.

find_package(rmw REQUIRED)
find_package(rosidl_runtime_c REQUIRED)
find_package(rosidl_typesupport_c REQUIRED)
find_package(rosidl_typesupport_interface REQUIRED)
find_package(Python3 REQUIRED COMPONENTS Interpreter)

# локальные переменные
set(_output_path
  "${CMAKE_CURRENT_BINARY_DIR}/rosidl_generator_lua/${PROJECT_NAME}")
set(_generated_c_files "")

# список имен файлов
foreach(_abs_idl_file ${rosidl_generate_interfaces_ABS_IDL_FILES})
  get_filename_component(_parent_folder "${_abs_idl_file}" DIRECTORY)
  get_filename_component(_parent_folder "${_parent_folder}" NAME)
  get_filename_component(_idl_name "${_abs_idl_file}" NAME_WE)
  string_camel_case_to_lower_case_underscore("${_idl_name}" _module_name)
  set(_src_c "${_output_path}/${_parent_folder}/${_module_name}.c")
  list(APPEND _generated_c_files ${_src_c})

  # msg / srv / action в разные списки
  if(${_parent_folder} STREQUAL "msg")
    list(APPEND _msg_list ${_src_c})
  endif()
endforeach()

file(MAKE_DIRECTORY "${_output_path}")

# поиск зависимостей
set(_dependency_files "")
set(_dependencies "")
foreach(_pkg_name ${rosidl_generate_interfaces_DEPENDENCY_PACKAGE_NAMES})
  foreach(_idl_file ${${_pkg_name}_IDL_FILES})
    set(_abs_idl_file "${${_pkg_name}_DIR}/../${_idl_file}")
    normalize_path(_abs_idl_file "${_abs_idl_file}")
    list(APPEND _dependency_files "${_abs_idl_file}")
    list(APPEND _dependencies "${_pkg_name}:${_abs_idl_file}")
  endforeach()
endforeach()

# создание файла настроек генератора
set(generator_arguments_file "${CMAKE_CURRENT_BINARY_DIR}/rosidl_generator_lua__arguments.json")
rosidl_write_generator_arguments(
  "${generator_arguments_file}"
  PACKAGE_NAME "${PROJECT_NAME}"
  IDL_TUPLES "${rosidl_generate_interfaces_IDL_TUPLES}"
  ROS_INTERFACE_DEPENDENCIES "${_dependencies}"
  OUTPUT_DIR "${_output_path}"
  TEMPLATE_DIR "${rosidl_generator_lua_TEMPLATE_DIR}"
  TARGET_DEPENDENCIES ${target_dependencies}
)

set_property(
  SOURCE ${_generated_c_files}
  PROPERTY GENERATED 1
)

# команда вызова функции скрипта из папки bin
add_custom_command(
  OUTPUT ${_generated_c_files}
  COMMAND Python3::Interpreter
  ARGS ${rosidl_generator_lua_BIN}
  --generator-arguments-file "${generator_arguments_file}"
  --typesupport-impls "${_typesupport_impls}"
  DEPENDS ${target_dependencies}
  COMMENT "Generating code for ROS interfaces"
  VERBATIM
)

# генерация C файлов
add_library(${_target_name_lib} SHARED ${_generated_c_files})
target_link_libraries(${_target_name_lib}
  ${rosidl_generate_interfaces_TARGET}__rosidl_generator_c)
add_dependencies(
  ${_target_name_lib}
  ${rosidl_generate_interfaces_TARGET}${_target_suffix}
  ${rosidl_generate_interfaces_TARGET}__rosidl_typesupport_c
)
rosidl_get_typesupport_target(c_typesupport_target "${rosidl_generate_interfaces_TARGET}" "rosidl_typesupport_c")

# сборка сообщений
if(NOT _msg_list STREQUAL "")
  add_library(msg SHARED ${_msg_list})
  set_target_properties(msg PROPERTIES
    PREFIX ""
    LIBRARY_OUTPUT_DIRECTORY ${_output_path}
  )
  target_link_libraries(msg ${c_typesupport_target})
  ament_target_dependencies(msg "rosidl_runtime_c")
endif()

# аналогично для сервисов и экшенов

Данный скрипт формирует список сообщений и их зависимостей, на основе собранных данных формирует JSON файл с описанием пакета и передаёт его генератору кода, после чего запускается компиляция динамических библиотек.

Сборка

Осталось сделать ещё несколько шагов, чтобы выполнить сборку. Во-первых, нужно создать в корне файл rosidl_generator_lua-extras.cmake.in с текстом

include("${CMAKE_CURRENT_LIST_DIR}/register_lua.cmake")
rosidl_generator_lua_extras(
  "${rosidl_generator_lua_DIR}/../../../lib/rosidl_generator_lua/rosidl_generator_lua"
  "${rosidl_generator_lua_DIR}/../../../@PYTHON_INSTALL_DIR@/rosidl_generator_lua/__init__.py"
  "${rosidl_generator_lua_DIR}/../resource"
)

Здесь прописаны пути к файлам и папкам, используемым в процессе генерации. Во-вторых, в package.xml добавляем зависимости:

<buildtool_depend>ament_cmake_export_assemblies</buildtool_depend>

  <buildtool_export_depend>ament_cmake</buildtool_export_depend>
  <buildtool_export_depend>rosidl_cmake</buildtool_export_depend>
  <buildtool_export_depend>rosidl_generator_c</buildtool_export_depend>
  <buildtool_export_depend>rosidl_typesupport_c</buildtool_export_depend>
  <buildtool_export_depend>rosidl_typesupport_interface</buildtool_export_depend>

  <build_depend>rosidl_runtime_c</build_depend>

  <exec_depend>rmw_implementation</exec_depend>
  <exec_depend>rmw_implementation_cmake</exec_depend>
  <exec_depend>rosidl_runtime_c</exec_depend>
  <exec_depend>rosidl_generator_c</exec_depend>
  <exec_depend>rosidl_parser</exec_depend>

  <member_of_group>rosidl_generator_packages</member_of_group>

Наконец, настраиваем сборку через CMakeLists.txt.

find_package(ament_cmake REQUIRED)
find_package(ament_cmake_python REQUIRED)

ament_export_dependencies(rosidl_cmake)
ament_export_dependencies(rmw)

ament_index_register_resource("rosidl_generator_packages")
ament_python_install_package(${PROJECT_NAME})

install(
  PROGRAMS bin/rosidl_generator_lua
  DESTINATION lib/rosidl_generator_lua
)

install(
  DIRECTORY cmake resource
  DESTINATION share/${PROJECT_NAME}
)

# добавляем свои сценарии сборки
ament_package(
  CONFIG_EXTRAS
    "cmake/rosidl_generator_lua_get_typesupports.cmake"
    "cmake/register_lua.cmake"
    "rosidl_generator_lua-extras.cmake.in"
)

Стандартные сообщения

Представленный выше код справляется с генерацией сообщений, но есть нюанс. Он ориентирован на работу с кастомными сообщениями, т.е. описание которых лежит в вашем локальном рабочем окружении. Но ROS включает в себя множество стандартных типов, таких как std_msgs, nav_msgs, sensor_msgs и т.д. И нужно как-то обеспечить возможность работы с ними.

Можно попытаться собрать стандартные библиотеки в своём локальном рабочем окружении. Однако в этом случае возникнет конфликт с глобальным окружением и ROS завершит работу. Я решил проблему следующим образом. Описания стандартных сообщений, т.е. msg и idl файлы, лежат в папке ros_distro_name/share. Я добавил отдельный пакет, который считывает эти описания, генерирует нужные динамические библиотеки и добавляет пути в LUA_CPATH. Это позволило иметь локальные версии библиотек не конфликтуя с глобальным окружением. Правда, пришлось использовать непубличные функции из ament_cmake, поэтому при обновлении версии ROS2 этот код, скорее всего, придётся дорабатывать.

Заключение

Мы рассмотрели, что нужно следать для того, чтобы генератор ROS2 сообщений подхватил ваши шаблоны и на их основе сформировал библиотеку под заданный язык программирования. Теперь можно переходить к имплементации издателей, подписчиков и прочего функционала, тесно связанного с хранением и обменом данными.