diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b911ebc --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,34 @@ +--- + +name: Build and test +on: + pull_request: + push: + branches: + - main + +jobs: + build_and_test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ros: [humble, jazzy, lyrical] + name: ROS 2 ${{ matrix.ros }} + container: + image: polymathrobotics/ros:${{ matrix.ros }}-builder-ubuntu + # rosdep installs pip deps (e.g. python3-cantools-pip) globally as root. + # On Python >= 3.11 (Ubuntu 24.04 images: jazzy, lyrical), PEP 668 requires + # opting in to installing alongside externally-managed packages. + env: + PIP_BREAK_SYSTEM_PACKAGES: 1 + steps: + - uses: actions/checkout@v4 + - uses: ros-tooling/action-ros-ci@v0.4 + with: + target-ros2-distro: ${{ matrix.ros }} + coverage-result: false + - uses: actions/upload-artifact@v4 + with: + name: colcon-logs-${{ matrix.ros }} + path: ros_ws/log diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..6a474d2 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,15 @@ +--- +name: pre-commit + +on: + pull_request: + push: + branches: [main] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v3 + - uses: pre-commit/action@v3.0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b02597d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.ruff.toml +/*.egg-info/ +__pycache__/ +/.pytest_tmp*/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c29423f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +--- +repos: + - repo: https://github.com/polymathrobotics/polymath_code_standard + rev: v2.2.0 + hooks: + # Basic checks and fixes that apply to any text file and the git repository itself + - id: polymath-general + - id: polymath-copyright + args: [--license, Apache-2.0, --copyright-org, 'Polymath Robotics, Inc.', --reuse-style] + # Specific languages + - id: polymath-python + - id: polymath-cpp + - id: polymath-ros + - id: polymath-shell + - id: polymath-cmake + - id: polymath-docker + - id: polymath-markdown + - id: polymath-xml + - id: polymath-yaml + - id: polymath-toml + - id: polymath-json diff --git a/LICENSE b/LICENSE index 261eeb9..01f9f9f 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026 Polymath Robotics, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index a9c9605..71c02fa 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,93 @@ -# dbc_gen_cpp -DBC CAN Message Type Generator for cpp +# DBC CAN Message Type Generator for C++ + +Extends the code generation capability of `cantools`, which outputs C sources from DBC frame definitions, to create idiomatic C++ interfaces for using CAN message types. + +## Usage + +In `package.xml`, you'll need this as a `build_depend`. + +In your `CMakeLists.txt`: + +```cmake +find_package(dbc_gen_cpp REQUIRED) +... + +generate_dbc_cpp(my_can_library_name + DBC ${CMAKE_CURRENT_SOURCE_DIR}/database.dbc +) + +... + +target_link_libraries(my_library PUBLIC my_can_library_name) +``` + +### CAN Messages + +Message structs are defined in the library name's namespace. + +They can be implicitly converted to `can_frame` from `` + +```c++ +#include "my_can_library_name/my_can_library_name.hpp" + +... + +my_can_library_name::MessageName message{value}; +my_can_socket->send(message); +can_frame frame = message; +my_can_socket->send(frame); + +``` + +### CAN Handler - Receive/Subscribe to CAN Messages + +A helper class `dbc_gen_cpp::CANHandler` is provided. + +Simply register a handler function for a type via `set_handler`, then forward all `can_frame`s received to the `handle()` method to trigger the registered handler functions with the typed structs. + +```c++ +dbc_gen_cpp::CANHandler handler; +handler.set_handler( + [](const my_can_library_name::MessageName & message) { + printf('Received MessageName (value %f)\n', message.value); + }); + +my_can_socket.on_receive( + [&](can_frame frame) { + handler.handle(frame); + }); +``` + +### J1939-Specific Handling + +#### **How is J1939 Defined in DBCs** +DBC files indicate whether a message uses **Standard CAN** or **J1939** via the two lines below. +The first line defines the attribute itself. The second line is applied **per message** and +should appear once for each message that is intended to be treated as J1939, using that +message’s specific CAN ID. + +``` +BA_DEF_ BO_ "VFrameFormat" ENUM "StandardCAN","ExtendedCAN","reserved","J1939PG"; +BA_ "VFrameFormat" BO_ 2364539904 3; +``` + +#### **J1939 Specific Constants** +The following will only be defined if a message is labeled as J1939 in the DBC: +- static constexpr uint32_t Pgn +- static constexpr uint8_t DefaultPriority = 3; +- static constexpr uint8_t SourceAddress = 37; +- static constexpr bool IsPduBroadcast = true; + +The following will only be defined if the message is J1939 and it's of type PDU1 (destination specific): +- static constexpr uint8_t DefaultDestinationAddress + +> Note: If you want to safely test if a message is of the J1939 Standard, use the variable `IsJ1939`. + +#### **J1939 Logic Changes** +1. When passing a can frame into the explicit constructor, it will only check if the PGN of the incoming frame matches, instead of the whole CAN ID. + +# Tests for dbc_gen_cpp + +Since [`dbc_gen_cpp`](dbc_gen_cpp/) provides mostly functionality via the `install/` space with CMake functions and a Python package with importlib-registered Jinja templates, it's not possible to test the full usage of that package internally. + +`test_dbc_gen_cpp` is fully dedicated to providing tests, it is not meant to be used as a dependency by any package. diff --git a/dbc_gen_cpp/CMakeLists.txt b/dbc_gen_cpp/CMakeLists.txt new file mode 100644 index 0000000..fe23ed3 --- /dev/null +++ b/dbc_gen_cpp/CMakeLists.txt @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +# SPDX-License-Identifier: Apache-2.0 +cmake_minimum_required(VERSION 3.22) +project(dbc_gen_cpp) + +if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 17) +endif() +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_compile_options(-Wall -Wextra -Wpedantic) + add_link_options(-Wl,-no-undefined) +endif() + +find_package(ament_cmake REQUIRED) +find_package(ament_cmake_python REQUIRED) + +ament_python_install_package(${PROJECT_NAME} + SCRIPTS_DESTINATION bin +) + +add_library(${PROJECT_NAME} INTERFACE) +target_include_directories(${PROJECT_NAME} INTERFACE + $ + $ +) + +install( + TARGETS ${PROJECT_NAME} + EXPORT ${PROJECT_NAME}Targets + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib + RUNTIME DESTINATION bin + INCLUDES DESTINATION include +) +install( + EXPORT ${PROJECT_NAME}Targets + NAMESPACE ${PROJECT_NAME}:: + DESTINATION share/${PROJECT_NAME}/cmake +) +install( + DIRECTORY include/ + DESTINATION include +) +install( + DIRECTORY cmake + DESTINATION share/${PROJECT_NAME} +) + +if(BUILD_TESTING) +endif() + +ament_export_targets(${PROJECT_NAME}Targets HAS_LIBRARY_TARGET) +ament_package( + CONFIG_EXTRAS "dbc_gen_cpp-extras.cmake" +) diff --git a/dbc_gen_cpp/cmake/generate_dbc_cpp.cmake b/dbc_gen_cpp/cmake/generate_dbc_cpp.cmake new file mode 100644 index 0000000..9ea17d5 --- /dev/null +++ b/dbc_gen_cpp/cmake/generate_dbc_cpp.cmake @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +# SPDX-License-Identifier: Apache-2.0 +function(generate_dbc_cpp library_name) + find_package(Python3 REQUIRED COMPONENTS Interpreter) + + set(one_value_args DBC) + cmake_parse_arguments(ARG "" "${one_value_args}" "" ${ARGN}) + if(NOT ARG_DBC) + message(FATAL_ERROR "generate_dbc_cpp: Missing required keyword argument DBC") + endif() + + set(gen_basedir ${CMAKE_CURRENT_BINARY_DIR}/dbc_gen_cpp) + set(gen_dir ${gen_basedir}/${library_name}) + + set(generated_c ${gen_dir}/${library_name}.c) + set(generated_h ${gen_dir}/${library_name}.h) + set(generated_hpp ${gen_dir}/${library_name}.hpp) + set(generated_files ${generated_c} ${generated_h} ${generated_hpp}) + + # Kind of awkward, but makes the sources get regenerated if the generator tool changes, by depending on the generator sources. + # Finds the python module directory via Python3 import + execute_process( + COMMAND ${Python3_EXECUTABLE} -c "import dbc_gen_cpp, os; print(os.path.dirname(dbc_gen_cpp.__file__))" + OUTPUT_VARIABLE GENERATOR_DIR + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + file(GLOB_RECURSE GENERATOR_SOURCES CONFIGURE_DEPENDS + "${GENERATOR_DIR}/*.py" + "${GENERATOR_DIR}/templates/*.j2" + ) + + # Generate the source files + add_custom_command( + OUTPUT ${generated_files} + COMMAND ${Python3_EXECUTABLE} -m dbc_gen_cpp ${ARG_DBC} -o ${gen_dir} -n ${library_name} + DEPENDS ${ARG_DBC} ${GENERATOR_SOURCES} + COMMENT "Generating C source from DBC ${ARG_DBC} with cantools" + VERBATIM + ) + add_custom_target(${library_name}_c_sources + DEPENDS ${generated_c} ${generated_h} + ) + + # Create the library target from generated sources + add_library(${library_name} STATIC ${generated_c}) + add_dependencies(${library_name} ${library_name}_c_sources) + target_link_libraries(${library_name} PUBLIC dbc_gen_cpp::dbc_gen_cpp) + target_include_directories(${library_name} + PUBLIC ${gen_basedir} + ) + + # Install the generated files to the install space, just in case they're included in public headers + install( + FILES ${generated_h} ${generated_hpp} + DESTINATION include/${library_name} + ) +endfunction() diff --git a/dbc_gen_cpp/dbc_gen_cpp-extras.cmake b/dbc_gen_cpp/dbc_gen_cpp-extras.cmake new file mode 100644 index 0000000..4ffe3ca --- /dev/null +++ b/dbc_gen_cpp/dbc_gen_cpp-extras.cmake @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +# SPDX-License-Identifier: Apache-2.0 +include("${dbc_gen_cpp_DIR}/generate_dbc_cpp.cmake") diff --git a/dbc_gen_cpp/dbc_gen_cpp/__init__.py b/dbc_gen_cpp/dbc_gen_cpp/__init__.py new file mode 100644 index 0000000..18a2699 --- /dev/null +++ b/dbc_gen_cpp/dbc_gen_cpp/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +# SPDX-License-Identifier: Apache-2.0 diff --git a/dbc_gen_cpp/dbc_gen_cpp/__main__.py b/dbc_gen_cpp/dbc_gen_cpp/__main__.py new file mode 100644 index 0000000..73670f9 --- /dev/null +++ b/dbc_gen_cpp/dbc_gen_cpp/__main__.py @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +# SPDX-License-Identifier: Apache-2.0 +from .generate_cpp import main + +if __name__ == '__main__': + main() diff --git a/dbc_gen_cpp/dbc_gen_cpp/generate_cpp.py b/dbc_gen_cpp/dbc_gen_cpp/generate_cpp.py new file mode 100644 index 0000000..66c0425 --- /dev/null +++ b/dbc_gen_cpp/dbc_gen_cpp/generate_cpp.py @@ -0,0 +1,110 @@ +# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +# SPDX-License-Identifier: Apache-2.0 +import argparse +import importlib.resources +from pathlib import Path + +from cantools import database +from cantools.database.can.c_source import CodeGenMessage, camel_to_snake_case +from cantools.database.can.c_source import generate as generate_c_source +from jinja2 import Environment + + +def parse_j1939_id(frame_id): + """Parse J1939 fields from a 29-bit CAN ID.""" + priority = (frame_id >> 26) & 0x7 + pf = (frame_id >> 16) & 0xFF + ps = (frame_id >> 8) & 0xFF + source_address = frame_id & 0xFF + r = (frame_id >> 25) & 0x1 + dp = (frame_id >> 24) & 0x1 + is_pdu_broadcast = pf >= 240 + if is_pdu_broadcast: + pgn = (r << 17) | (dp << 16) | (pf << 8) | ps + else: + pgn = (r << 17) | (dp << 16) | (pf << 8) + result = { + 'pgn': pgn, + 'priority': priority, + 'source_address': source_address, + 'pdu_format': pf, + 'pdu_specific': ps, + 'is_pdu_broadcast': is_pdu_broadcast, + } + # For PDU1 (destination-specific) messages, PS is the destination address + if not is_pdu_broadcast: + result['destination_address'] = ps + return result + + +def generate_cpp_source(args): + dbase = database.load_file(args.infile) + database_name: str = args.database_name or camel_to_snake_case(args.infile.stem) + filename_h = f'{database_name}.h' + filename_c = f'{database_name}.c' + filename_hpp = f'{database_name}.hpp' + + c_header, c_source, _, _ = generate_c_source( + dbase, + database_name, + filename_h, + filename_c, + f'{database_name}_fuzzer.c', + ) + + outdir = args.output_directory + outdir.mkdir(parents=True, exist_ok=True) + with (outdir / filename_h).open('w') as f: + f.write(c_header) + with (outdir / filename_c).open('w') as f: + f.write(c_source) + print(f'Successfully generated C source files: {outdir / filename_h}, {outdir / filename_c}') + + jinja_env = Environment(trim_blocks=True, lstrip_blocks=True) + + template_path = importlib.resources.files('dbc_gen_cpp.templates') + with template_path.joinpath('can.hpp.j2').open('r') as f: + hpp_template_src = f.read() + hpp_template = jinja_env.from_string(hpp_template_src) + + message_types = [] + for message in dbase.messages: + cg_message = CodeGenMessage(message) + + msg_dict = { + 'name': message.name, + 'struct_name': f'{database_name}_{cg_message.snake_name}', + 'can_id': message.frame_id, + 'is_extended_frame': message.is_extended_frame, + 'protocol': message.protocol, + 'data_length': message.length, + 'signals': [ + { + 'name': cg_signal.snake_name, + 'type_name': cg_signal.type_name, + } + for cg_signal in cg_message.cg_signals + ], + } + if message.protocol == 'j1939': + msg_dict['j1939'] = parse_j1939_id(message.frame_id) + message_types.append(msg_dict) + hpp_src = hpp_template.render(library_name=database_name, messages=message_types, c_header=filename_h) + + with (outdir / filename_hpp).open('w') as f: + f.write(hpp_src) + + print(f'Successfully generated C++ source files: {outdir / filename_hpp}') + + +def main(): + parser = argparse.ArgumentParser('dbc_gen_cpp', description='Generate C++ code from DBC files.') + parser.add_argument('infile', type=Path, help='Path to the DBC file.') + parser.add_argument('-o', '--output-directory', type=Path, help='Directory to save the generated sources.') + parser.add_argument('-n', '--database-name', type=str, default=None) + args = parser.parse_args() + generate_cpp_source(args) + + +if __name__ == '__main__': + main() diff --git a/dbc_gen_cpp/dbc_gen_cpp/templates/can.hpp.j2 b/dbc_gen_cpp/dbc_gen_cpp/templates/can.hpp.j2 new file mode 100644 index 0000000..a196261 --- /dev/null +++ b/dbc_gen_cpp/dbc_gen_cpp/templates/can.hpp.j2 @@ -0,0 +1,127 @@ +{# Note: This .j2 template is NOT auto-generated, but the comment below must appear in generated output files. #} +// This file was auto-generated from can.hpp.j2 by dbc_gen_cpp + +#pragma once + +#include + +#include +#include + +#include "{{ library_name }}/{{ c_header }}" + +namespace {{ library_name }} +{ + +{% for message in messages %} +struct {{ message.name }} +{ + static constexpr uint32_t Id = {{ message.can_id }}; + static constexpr uint8_t DataLength = {{ message.data_length }}; + + // Frame format constants + static constexpr bool IsJ1939 = {{ 'true' if message.protocol == 'j1939' else 'false' }}; + static constexpr bool IsExtendedFrame = {{ 'true' if message.is_extended_frame else 'false' }}; + {% if message.protocol == 'j1939' %} + // PGN, Priority, Source Address, and PDU type only generate for J1939 messages + static constexpr uint32_t Pgn = {{ message.j1939.pgn }}; + static constexpr uint8_t DefaultPriority = {{ message.j1939.priority }}; + static constexpr uint8_t SourceAddress = {{ message.j1939.source_address }}; + static constexpr bool IsPduBroadcast = {{ 'true' if message.j1939.is_pdu_broadcast else 'false' }}; + {% if not message.j1939.is_pdu_broadcast %} + // DefaultDestinationAddress only generates for PDU1 destination-specific messages + static constexpr uint8_t DefaultDestinationAddress = {{ message.j1939.destination_address }}; + {% endif %} + + static bool matchesPgn(uint32_t can_id) + { + {% if message.j1939.is_pdu_broadcast %} + return ((can_id >> 8) & 0x3FFFF) == Pgn; + {% else %} + // PDU1: PS field is destination address, not part of PGN + return ((can_id >> 8) & 0x3FF00) == Pgn; + {% endif %} + } + {% endif %} + + // Signals + {% for signal in message.signals %} + double {{ signal.name }}; + {% endfor %} + + // Default constructor + {{ message.name }}() = default; + + {% if message.signals|length == 1 %} + // Single-value constructor + {% set signal = message.signals[0] %} + explicit {{ message.name }}(double {{ message.signals[0].name }}) + : {{ signal.name }}({{ signal.name }}) + {} + {% endif %} + + // Constructor from can_frame + explicit {{ message.name }}(const can_frame & frame) + { + {% if message.protocol == 'j1939' %} + // Just check PGN match for J1939 messages + if (!matchesPgn(frame.can_id & CAN_EFF_MASK)) { + {% elif message.is_extended_frame %} + // Apply CAN_EFF_FLAG mask because we expect an extended frame + if (frame.can_id != (Id | CAN_EFF_FLAG)) { + {% else %} + if (frame.can_id != Id) { + {% endif %} + {% if message.protocol == 'j1939' %} + throw std::runtime_error("CAN Frame ID does not match this message type PGN"); + {% else %} + throw std::runtime_error("CAN Frame ID does not match this message type ID"); + {% endif %} + } + + {# Don't try to unpack if there are no signals #} + {% if message.signals|length > 0 %} + {{ message.struct_name }}_t unpacked; + if (0 != {{ message.struct_name }}_init(&unpacked)) { + throw std::runtime_error("Failed to initialize frame data struct"); + } + if (0 != {{ message.struct_name }}_unpack(&unpacked, frame.data, frame.can_dlc)) { + throw std::runtime_error("Failed to unpack can frame data"); + } + {% for signal in message.signals %} + {{ signal.name }} = {{ message.struct_name }}_{{ signal.name }}_decode(unpacked.{{ signal.name }}); + {% endfor %} + {% endif %} + } + + // Conversion operator to can_frame + operator can_frame() const + { + can_frame frame; + {% if message.is_extended_frame %} + // Set Extended Frame Flag for linux/can.h standard + frame.can_id = Id | CAN_EFF_FLAG; + {% else %} + frame.can_id = Id; + {% endif %} + frame.len = DataLength; + + {# Don't try to unpack if there are no signals #} + {% if message.signals|length > 0 %} + {{ message.struct_name }}_t unpacked; + if (0 != {{ message.struct_name }}_init(&unpacked)) { + throw std::runtime_error("Unable to initialize frame struct"); + } + {% for signal in message.signals %} + unpacked.{{ signal.name }} = {{ message.struct_name }}_{{ signal.name }}_encode({{ signal.name }}); + {% endfor %} + if (0 >= {{ message.struct_name }}_pack(frame.data, &unpacked, DataLength)) { + throw std::runtime_error("Failed to pack can frame data"); + } + {% endif %} + return frame; + } +}; + +{% endfor %} +} // namespace {{ library_name }} diff --git a/dbc_gen_cpp/include/dbc_gen_cpp/can_handler.hpp b/dbc_gen_cpp/include/dbc_gen_cpp/can_handler.hpp new file mode 100644 index 0000000..158128a --- /dev/null +++ b/dbc_gen_cpp/include/dbc_gen_cpp/can_handler.hpp @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace dbc_gen_cpp +{ + +/// @brief Router for can frames to typed callbacks +/// Supports both standard CAN (exact ID match) and J1939 (PGN match) +class CANHandler +{ +public: + /// @brief Register a callback function for a given can message type + /// @tparam T + /// @param cb + template + void set_handler(std::function cb) + { + static_assert(T::Id != 0, "CAN ID must be non-zero"); + if constexpr (T::IsJ1939) { + // Match by PGN for J1939 messages + j1939_handlers_[T::Pgn] = std::make_unique>(std::move(cb)); + } else if constexpr (T::IsExtendedFrame) { + // Include EFF flag when comparing with raw linux/can.h can frames. + handlers_[T::Id | CAN_EFF_FLAG] = std::make_unique>(std::move(cb)); + } else { + // Standard CAN message + handlers_[T::Id] = std::make_unique>(std::move(cb)); + } + } + + /// @brief Route a can frame to the appropriate handler, if registered + /// @param frame + /// @return true if there was a registered handler that was called + bool handle(const can_frame & frame) const + { + // Standard CAN: exact ID match + auto it = handlers_.find(frame.can_id); + if (it != handlers_.end()) { + it->second->invoke(frame); + return true; + } + + // J1939: extract PGN and match (only for extended frames) + if (frame.can_id & CAN_EFF_FLAG) { + uint32_t pgn = extractPgn(frame.can_id); + auto j1939_it = j1939_handlers_.find(pgn); + if (j1939_it != j1939_handlers_.end()) { + j1939_it->second->invoke(frame); + return true; + } + } + + return false; + } + +private: + /// @brief Extract PGN from a 29-bit J1939 CAN ID + /// @param can_id The CAN ID with EFF mask already applied + /// @return The PGN value + static uint32_t extractPgn(uint32_t can_id) + { + uint8_t pf = (can_id >> 16) & 0xFF; + if (pf >= 240) { + // PDU2 (broadcast): PGN includes PS field + return (can_id >> 8) & 0x3FFFF; + } else { + // PDU1 (destination-specific): PGN excludes PS field + return (can_id >> 8) & 0x3FF00; + } + } + + /// @brief Type-erased pure virtual interface class for can message callback functions + struct ICanMessageHandler + { + virtual ~ICanMessageHandler() = default; + virtual void invoke(const can_frame &) const = 0; + }; + + /// @brief Concrete instantiation of the handler interface for a given message type + /// @tparam T CAN message class + template + struct CanMessageHandlerImpl : ICanMessageHandler + { + std::function callback; + + explicit CanMessageHandlerImpl(std::function cb) + : callback(std::move(cb)) + {} + + void invoke(const can_frame & frame) const override + { + callback(T(frame)); + } + }; + + std::unordered_map> handlers_; // Standard CAN: keyed by ID + std::unordered_map> j1939_handlers_; // J1939: keyed by PGN +}; + +} // namespace dbc_gen_cpp diff --git a/dbc_gen_cpp/package.xml b/dbc_gen_cpp/package.xml new file mode 100644 index 0000000..3649ff8 --- /dev/null +++ b/dbc_gen_cpp/package.xml @@ -0,0 +1,19 @@ + + + + dbc_gen_cpp + 0.0.1 + Generate C++ types for CAN messages from a DBC + Polymath Robotics Engineering + Apache-2.0 + + ament_cmake + ament_cmake_python + + python3-cantools-pip + python3-jinja2 + + + ament_cmake + + diff --git a/dbc_gen_cpp/setup.cfg b/dbc_gen_cpp/setup.cfg new file mode 100644 index 0000000..97b16d3 --- /dev/null +++ b/dbc_gen_cpp/setup.cfg @@ -0,0 +1,7 @@ +[options.entry_points] +console_scripts = + can_dbc_generate_cpp = dbc_gen_cpp.generate_cpp:main + +[options.package_data] +dbc_gen_cpp = + templates/*.j2 diff --git a/test_dbc_gen_cpp/CMakeLists.txt b/test_dbc_gen_cpp/CMakeLists.txt new file mode 100644 index 0000000..753dc71 --- /dev/null +++ b/test_dbc_gen_cpp/CMakeLists.txt @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +# SPDX-License-Identifier: Apache-2.0 + +cmake_minimum_required(VERSION 3.22) +project(test_dbc_gen_cpp) + +if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 17) +endif() +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_compile_options(-Wall -Wextra -Wpedantic) + add_link_options(-Wl,-no-undefined) +endif() + +find_package(ament_cmake_auto REQUIRED) + +if(BUILD_TESTING) + ament_auto_find_test_dependencies() + + find_package(ament_cmake_test REQUIRED) + find_package(Catch2 REQUIRED) + + generate_dbc_cpp(fake_vehicle_can + DBC ${CMAKE_CURRENT_SOURCE_DIR}/test/FakeVehicle.dbc + ) + + generate_dbc_cpp(fake_j1939_can + DBC ${CMAKE_CURRENT_SOURCE_DIR}/test/FakeJ1939.dbc + ) + + # Build the Catch2-based unit test and register it with ament. + add_executable(test_dbc_cpp test/test_dbc_cpp.cpp) + target_link_libraries(test_dbc_cpp + Catch2::Catch2WithMain + fake_vehicle_can + fake_j1939_can + ) + ament_target_dependencies(test_dbc_cpp dbc_gen_cpp) + target_include_directories(test_dbc_cpp PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/test) + + ament_add_test( + test_dbc_cpp + GENERATE_RESULT_FOR_RETURN_CODE_ZERO + COMMAND "$" + -r junit + -s + -o test_results/${PROJECT_NAME}/test_dbc_cpp_output.xml + ENV CATCH_CONFIG_CONSOLE_WIDTH=120 + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" + ) +endif() + +ament_package() diff --git a/test_dbc_gen_cpp/package.xml b/test_dbc_gen_cpp/package.xml new file mode 100644 index 0000000..548fcd9 --- /dev/null +++ b/test_dbc_gen_cpp/package.xml @@ -0,0 +1,19 @@ + + + + test_dbc_gen_cpp + 0.0.1 + Tests for dbc_gen_cpp package + Polymath Robotics Engineering + Apache-2.0 + + ament_cmake + ament_cmake_auto + + catch2 + dbc_gen_cpp + + + ament_cmake + + diff --git a/test_dbc_gen_cpp/test/FakeJ1939.dbc b/test_dbc_gen_cpp/test/FakeJ1939.dbc new file mode 100644 index 0000000..0fae8bf --- /dev/null +++ b/test_dbc_gen_cpp/test/FakeJ1939.dbc @@ -0,0 +1,68 @@ +VERSION "Fake J1939 DBC for testing" + +NS_ : + NS_DESC_ + CM_ + BA_DEF_ + BA_ + VAL_ + CAT_DEF_ + CAT_ + FILTER + BA_DEF_DEF_ + EV_DATA_ + ENVVAR_DATA_ + SGTYPE_ + SGTYPE_VAL_ + BA_DEF_SGTYPE_ + BA_SGTYPE_ + SIG_TYPE_REF_ + VAL_TABLE_ + SIG_GROUP_ + SIG_VALTYPE_ + SIGTYPE_VALTYPE_ + BO_TX_BU_ + BA_DEF_REL_ + BA_REL_ + BA_DEF_DEF_REL_ + BU_SG_REL_ + BU_EV_REL_ + BU_BO_REL_ + SG_MUL_VAL_ + +BS_: + +BU_: ECU1 ECU2 + +BO_ 2364539904 EngineData: 8 ECU1 + SG_ engine_rpm : 0|16@1+ (1,0) [0|65535] "rpm" ECU2 + SG_ engine_load : 16|8@1+ (1,0) [0|255] "%" ECU2 + +BO_ 2566844672 VehicleSpeed: 8 ECU1 + SG_ speed : 0|16@1+ (1,0) [0|65535] "km/h" ECU2 + +BO_ 2563440384 TorqueCommand: 8 ECU2 + SG_ torque_request : 0|8@1+ (1,0) [0|255] "%" ECU1 + +BO_ 2361921573 BrakeCommand: 8 ECU2 + SG_ brake_pressure : 0|16@1+ (0.1,0) [0|6553.5] "bar" ECU1 + SG_ brake_mode : 16|8@1+ (1,0) [0|255] "" ECU1 + +BO_ 2147484647 EmptyJ1939Message: 0 ECU2 + +BO_ 300 BatteryStatus: 4 ECU1 + SG_ battery_voltage : 0|16@1+ (0.01,0) [0|655.35] "V" ECU2 + SG_ battery_soc : 16|8@1+ (0.5,0) [0|100] "%" ECU2 + +BA_DEF_ BO_ "VFrameFormat" ENUM "StandardCAN","ExtendedCAN","reserved","J1939PG"; +BA_DEF_ "ProtocolType" STRING ; +BA_DEF_DEF_ "VFrameFormat" ""; +BA_DEF_DEF_ "ProtocolType" ""; +BA_ "ProtocolType" "J1939"; +BA_ "VFrameFormat" BO_ 2364539904 3; +BA_ "VFrameFormat" BO_ 2566844672 3; +BA_ "VFrameFormat" BO_ 2563440384 3; +BA_ "VFrameFormat" BO_ 2361921573 3; +BA_ "VFrameFormat" BO_ 2147484647 3; + +CM_ BO_ 300 "Add one non-J1939 Message to test having a single non-J1939 message in a J1939 DBC"; diff --git a/test_dbc_gen_cpp/test/FakeVehicle.dbc b/test_dbc_gen_cpp/test/FakeVehicle.dbc new file mode 100644 index 0000000..fcf16ee --- /dev/null +++ b/test_dbc_gen_cpp/test/FakeVehicle.dbc @@ -0,0 +1,73 @@ +VERSION "A fake vehicle CAN profile for testing" + +NS_ : + NS_DESC_ + CM_ + BA_DEF_ + BA_ + VAL_ + CAT_DEF_ + CAT_ + FILTER + BA_DEF_DEF_ + EV_DATA_ + ENVVAR_DATA_ + SGTYPE_ + SGTYPE_VAL_ + BA_DEF_SGTYPE_ + BA_SGTYPE_ + SIG_GROUP_ + SIG_VALTYPE_ + SIGTYPE_VALTYPE_ + BO_TX_BU_ + BA_DEF_REL_ + BA_REL_ + BA_DEF_DEF_REL_ + BU_SG_REL_ + BU_EV_REL_ + BU_BO_REL_ + SG_MUL_VAL_ + +BS_: + +BU_: Vehicle Computer + +BO_ 1 Heartbeat: 1 Computer + SG_ alive : 0|1@1+ (1,0) [0|1] "bool" Vehicle + +BO_ 2 ReadyForAutonomy: 1 Vehicle + SG_ ready : 0|1@1+ (1,0) [0|1] "bool" Computer + +BO_ 100 SpeedFeedback: 4 Vehicle + SG_ speed : 0|32@1- (1,0) [0|0] "m/s" Computer + +BO_ 101 SteeringFeedback: 1 Vehicle + SG_ angle : 0|8@1- (1,0) [0|180] "degrees" Computer + +BO_ 200 DriveCommand: 8 Computer + SG_ drive_speed : 0|32@1- (1,0) [0|0] "m/s" Vehicle + SG_ drive_angle : 32|32@1- (1,0) [0|0] "rad/s" Vehicle + +BO_ 201 Fuel_Tank_Level: 2 Computer + SG_ fuel_tank_level : 0|16@1+ (0.0015258789,0) [0|100] "%" Vehicle + +BO_ 999 EmptyMessage: 0 Computer + +BO_ 2147483848 ExtendedDiagnostic: 8 Computer + SG_ diagnostic_code : 0|16@1+ (1,0) [0|65535] "" Vehicle + SG_ fault_count : 16|8@1+ (1,0) [0|255] "" Vehicle + +BO_ 2566859520 EngineAuxStatus: 8 Vehicle + SG_ aux_coolant_temp : 0|8@1+ (1,-40) [-40|210] "degC" Computer + SG_ aux_oil_pressure : 8|8@1+ (4,0) [0|1000] "kPa" Computer + +BA_DEF_ BO_ "VFrameFormat" ENUM "StandardCAN","ExtendedCAN","reserved","J1939PG"; +BA_DEF_DEF_ "VFrameFormat" ""; +BA_ "VFrameFormat" BO_ 2566859520 3; + +SIG_VALTYPE_ 100 speed : 1; +SIG_VALTYPE_ 200 drive_speed : 1; +SIG_VALTYPE_ 200 drive_angle : 1; + +CM_ BO_ 2147483848 "CAN ID 200 (0xC8) with extended frame flag (0x80000000) set"; +CM_ BO_ 2566859520 "Add one J1939 Message to test having a single J1939 message in a non-J1939 DBC"; diff --git a/test_dbc_gen_cpp/test/test_dbc_cpp.cpp b/test_dbc_gen_cpp/test/test_dbc_cpp.cpp new file mode 100644 index 0000000..ad1a1bb --- /dev/null +++ b/test_dbc_gen_cpp/test/test_dbc_cpp.cpp @@ -0,0 +1,956 @@ +// SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include "dbc_gen_cpp/can_handler.hpp" +#include "fake_j1939_can/fake_j1939_can.hpp" +#include "fake_vehicle_can/fake_vehicle_can.hpp" +#include "test_dbc_gen_cpp/catch2_compat.hpp" + +// ============================================================================ +// CAN Encode/Decode Tests (with byte layout verification) +// ============================================================================ +// These tests verify exact byte layouts to ensure encoding/decoding is correct. + +TEST_CASE("CAN encode - Standard frame byte layout") +{ + SECTION("Single 8-bit signal") + { + // SteeringFeedback: angle is 8-bit signed at bits 0-7 + fake_vehicle_can::SteeringFeedback msg{25}; + can_frame frame = msg; + + // Verify the raw byte value + REQUIRE(frame.can_id == 101); + REQUIRE(frame.len == 1); + REQUIRE(frame.data[0] == 25); + } + + SECTION("Single boolean signal") + { + fake_vehicle_can::Heartbeat msg{}; + msg.alive = 1; + can_frame frame = msg; + + REQUIRE(frame.can_id == 1); + REQUIRE(frame.len == 1); + REQUIRE((frame.data[0] & 0x01) == 1); + } + + SECTION("Multi-signal message byte positions") + { + // ExtendedDiagnostic: diagnostic_code (16-bit at 0-15), fault_count (8-bit at 16-23) + fake_vehicle_can::ExtendedDiagnostic msg{}; + msg.diagnostic_code = 0x1234; + msg.fault_count = 0x56; + can_frame frame = msg; + + // Verify EFF flag is set for extended frame + REQUIRE((frame.can_id & CAN_EFF_FLAG) != 0); + REQUIRE((frame.can_id & CAN_EFF_MASK) == fake_vehicle_can::ExtendedDiagnostic::Id); + + // Verify little-endian encoding of 16-bit value + REQUIRE(frame.data[0] == 0x34); // Low byte of diagnostic_code + REQUIRE(frame.data[1] == 0x12); // High byte of diagnostic_code + REQUIRE(frame.data[2] == 0x56); // fault_count + } + + SECTION("Scaled signal encoding") + { + // Fuel_Tank_Level: scale=0.0015258789, 16-bit unsigned + // Value 50.0 / 0.0015258789 ≈ 32768 + fake_vehicle_can::Fuel_Tank_Level msg{50.0}; + can_frame frame = msg; + + uint16_t raw_value = frame.data[0] | (frame.data[1] << 8); + // 50.0 / 0.0015258789 = 32768.0 (approximately) + REQUIRE(raw_value == Approx(32768).margin(1)); + } +} + +TEST_CASE("CAN decode - Standard frame byte layout") +{ + SECTION("Single unsigned integer signal") + { + can_frame frame{}; + frame.can_id = 101; + frame.can_dlc = 1; + frame.data[0] = 42; + + fake_vehicle_can::SteeringFeedback msg{frame}; + REQUIRE(msg.angle == 42); + } + + SECTION("Multi-signal message from raw bytes") + { + can_frame frame{}; + frame.can_id = fake_vehicle_can::ExtendedDiagnostic::Id | CAN_EFF_FLAG; + frame.can_dlc = 8; + frame.data[0] = 0xCD; // Low byte of diagnostic_code + frame.data[1] = 0xAB; // High byte of diagnostic_code = 0xABCD + frame.data[2] = 0x07; // fault_count = 7 + memset(&frame.data[3], 0, 5); + + fake_vehicle_can::ExtendedDiagnostic msg{frame}; + REQUIRE(msg.diagnostic_code == 0xABCD); + REQUIRE(msg.fault_count == 7); + } + + SECTION("Scaled signal decoding") + { + can_frame frame{}; + frame.can_id = 201; + frame.can_dlc = 2; + // Raw value 32768 * 0.0015258789 ≈ 50.0 + frame.data[0] = 0x00; // Low byte + frame.data[1] = 0x80; // High byte = 0x8000 = 32768 + + fake_vehicle_can::Fuel_Tank_Level msg{frame}; + REQUIRE(msg.fuel_tank_level == Approx(50.0).margin(0.01)); + } +} + +TEST_CASE("CAN encode - J1939 frame byte layout") +{ + SECTION("PDU2 message with multiple signals") + { + // EngineData: engine_rpm (16-bit at 0-15), engine_load (8-bit at 16-23) + fake_j1939_can::EngineData msg{}; + msg.engine_rpm = 1000; + msg.engine_load = 75; + can_frame frame = msg; + + // Verify EFF flag is set + REQUIRE((frame.can_id & CAN_EFF_FLAG) != 0); + REQUIRE((frame.can_id & CAN_EFF_MASK) == fake_j1939_can::EngineData::Id); + + // Verify raw bytes (little-endian) + REQUIRE(frame.data[0] == (1000 & 0xFF)); // Low byte of engine_rpm + REQUIRE(frame.data[1] == ((1000 >> 8) & 0xFF)); // High byte of engine_rpm + REQUIRE(frame.data[2] == 75); // engine_load + } + + SECTION("PDU1 message with scaled signal") + { + // BrakeCommand: brake_pressure (16-bit, scale=0.1), brake_mode (8-bit) + fake_j1939_can::BrakeCommand msg{}; + msg.brake_pressure = 100.0; // Raw value = 100.0 / 0.1 = 1000 + msg.brake_mode = 5; + can_frame frame = msg; + + uint16_t raw_pressure = frame.data[0] | (frame.data[1] << 8); + REQUIRE(raw_pressure == 1000); + REQUIRE(frame.data[2] == 5); + } +} + +TEST_CASE("CAN decode - J1939 frame byte layout") +{ + SECTION("PDU2 message from raw bytes") + { + can_frame frame{}; + frame.can_id = fake_j1939_can::EngineData::Id | CAN_EFF_FLAG; + frame.can_dlc = 8; + // engine_rpm = 2500 (0x09C4) + frame.data[0] = 0xC4; + frame.data[1] = 0x09; + // engine_load = 80 + frame.data[2] = 80; + memset(&frame.data[3], 0, 5); + + fake_j1939_can::EngineData msg{frame}; + REQUIRE(msg.engine_rpm == 2500); + REQUIRE(msg.engine_load == 80); + } + + SECTION("PDU2 message from different source address") + { + can_frame frame{}; + // Same PGN but different source address (SA=0x42) + frame.can_id = ((fake_j1939_can::EngineData::Id & ~0xFF) | 0x42) | CAN_EFF_FLAG; + frame.can_dlc = 8; + frame.data[0] = 0xE8; // engine_rpm = 1000 (0x03E8) + frame.data[1] = 0x03; + frame.data[2] = 50; // engine_load = 50 + memset(&frame.data[3], 0, 5); + + fake_j1939_can::EngineData msg{frame}; + REQUIRE(msg.engine_rpm == 1000); + REQUIRE(msg.engine_load == 50); + } + + SECTION("PDU1 message with scaled signal from raw bytes") + { + can_frame frame{}; + frame.can_id = fake_j1939_can::BrakeCommand::Id | CAN_EFF_FLAG; + frame.can_dlc = 8; + // brake_pressure raw = 500, scaled = 500 * 0.1 = 50.0 + frame.data[0] = 0xF4; // 500 & 0xFF + frame.data[1] = 0x01; // 500 >> 8 + frame.data[2] = 3; // brake_mode = 3 + memset(&frame.data[3], 0, 5); + + fake_j1939_can::BrakeCommand msg{frame}; + REQUIRE(msg.brake_pressure == 50.0); + REQUIRE(msg.brake_mode == 3); + } +} + +TEST_CASE("CAN roundtrip - Encode/decode with byte verification") +{ + // This test verifies that encoding then decoding produces the same values, + // but also checks the intermediate raw bytes match expectations. + + SECTION("Standard CAN roundtrip with byte verification") + { + fake_vehicle_can::ExtendedDiagnostic original{}; + original.diagnostic_code = 0xBEEF; + original.fault_count = 0x42; + + can_frame frame = original; + + // Verify raw bytes + REQUIRE(frame.data[0] == 0xEF); + REQUIRE(frame.data[1] == 0xBE); + REQUIRE(frame.data[2] == 0x42); + + // Verify roundtrip + fake_vehicle_can::ExtendedDiagnostic decoded{frame}; + REQUIRE(decoded.diagnostic_code == 0xBEEF); + REQUIRE(decoded.fault_count == 0x42); + } + + SECTION("J1939 roundtrip with byte verification") + { + fake_j1939_can::EngineData original{}; + original.engine_rpm = 3500; + original.engine_load = 90; + + can_frame frame = original; + + // Verify raw bytes (3500 = 0x0DAC) + REQUIRE(frame.data[0] == 0xAC); + REQUIRE(frame.data[1] == 0x0D); + REQUIRE(frame.data[2] == 90); + + // Verify roundtrip + fake_j1939_can::EngineData decoded{frame}; + REQUIRE(decoded.engine_rpm == 3500); + REQUIRE(decoded.engine_load == 90); + } +} + +TEST_CASE("CAN encode/decode - Messages with 0 length payload") +{ + SECTION("Standard CAN message with 0 length payload") + { + fake_vehicle_can::EmptyMessage msg{}; + can_frame frame = msg; + + REQUIRE(frame.can_id == fake_vehicle_can::EmptyMessage::Id); + REQUIRE(frame.len == 0); + REQUIRE(fake_vehicle_can::EmptyMessage::DataLength == 0); + REQUIRE(fake_vehicle_can::EmptyMessage::IsJ1939 == false); + REQUIRE(fake_vehicle_can::EmptyMessage::IsExtendedFrame == false); + + // Verify decode doesn't throw + fake_vehicle_can::EmptyMessage decoded{frame}; + (void)decoded; // No signals to verify + } + + SECTION("J1939 message with 0 length payload") + { + fake_j1939_can::EmptyJ1939Message msg{}; + can_frame frame = msg; + + REQUIRE((frame.can_id & CAN_EFF_FLAG) != 0); + REQUIRE((frame.can_id & CAN_EFF_MASK) == fake_j1939_can::EmptyJ1939Message::Id); + REQUIRE(frame.len == 0); + REQUIRE(fake_j1939_can::EmptyJ1939Message::DataLength == 0); + REQUIRE(fake_j1939_can::EmptyJ1939Message::IsJ1939 == true); + REQUIRE(fake_j1939_can::EmptyJ1939Message::IsExtendedFrame == true); + + // Verify decode doesn't throw + fake_j1939_can::EmptyJ1939Message decoded{frame}; + (void)decoded; // No signals to verify + } +} + +// ============================================================================ +// Signal Precision Tests +// ============================================================================ +// Because some signals with scaling factors are encoded into signals with +// fewer bits than a standard float, some truncation may occur. These tests +// verify that the decoded values are within the expected precision limits +// (aka the scaling factor). + +TEST_CASE("CAN encode/decode - Scaled signal precision") +{ + { + fake_vehicle_can::Fuel_Tank_Level msg(67.55f); + can_frame frame = msg; + fake_vehicle_can::Fuel_Tank_Level re_parsed{frame}; + + // Margin accounts for precision loss: the DBC scaling factor (0.0015258789) truncates + // the value to 2 bytes during encoding, then scales it back during decoding. + REQUIRE(67.55f == Approx(re_parsed.fuel_tank_level).margin(0.0015258789f)); + } + + { + fake_vehicle_can::Fuel_Tank_Level msg(37.38f); + can_frame frame = msg; + fake_vehicle_can::Fuel_Tank_Level re_parsed{frame}; + + // Require the signal to be within resolution of 0.0015258789 of the original value + // since that is the scaling factor defined in the DBC + REQUIRE(37.38f == Approx(re_parsed.fuel_tank_level).margin(0.0015258789f)); + } +} + +// ============================================================================ +// EFF and Mismatched ID/PGN Error Handling Tests +// ============================================================================ + +TEST_CASE("Extended frame ID validation") +{ + SECTION("Extended CAN ID rejects frame without EFF flag") + { + can_frame frame; + // Same ID but WITHOUT EFF flag - should be rejected + frame.can_id = fake_vehicle_can::ExtendedDiagnostic::Id; // No CAN_EFF_FLAG + frame.can_dlc = 8; + memset(frame.data, 0, 8); + + REQUIRE_THROWS_WITH( + fake_vehicle_can::ExtendedDiagnostic{frame}, "CAN Frame ID does not match this message type ID"); + } + + SECTION("Extended CAN ID doesn't match arbitrary standard ID") + { + // ExtendedDiagnostic::Id = 0x800000C8 (includes extended frame indicator from DBC) + // This tests that an arbitrary standard frame won't be parsed as an extended frame + + can_frame standard_frame; + standard_frame.can_id = 0xC8; // Standard frame with same lower bits as ExtendedDiagnostic + standard_frame.can_dlc = 8; + memset(standard_frame.data, 0, 8); + + // Should throw because IDs don't match (one is standard, one is extended) + REQUIRE_THROWS_WITH( + fake_vehicle_can::ExtendedDiagnostic{standard_frame}, "CAN Frame ID does not match this message type ID"); + } +} + +TEST_CASE("J1939 constructor rejects wrong PGN") +{ + can_frame frame{}; + frame.can_dlc = 8; + memset(frame.data, 0, 8); + + // Wrong PGN for EngineData (PGN 61445 instead of 61444) + frame.can_id = 0x0CF00500 | CAN_EFF_FLAG; + REQUIRE_THROWS(fake_j1939_can::EngineData{frame}); + + // Wrong PGN for TorqueCommand (PF=0xCB instead of 0xCA) + frame.can_id = 0x18CBFF00 | CAN_EFF_FLAG; + REQUIRE_THROWS(fake_j1939_can::TorqueCommand{frame}); +} + +TEST_CASE("Standard CAN ID mismatch throws") +{ + can_frame frame; + frame.can_id = 100; + frame.can_dlc = 4; + memset(frame.data, 0, 8); + + REQUIRE_THROWS(fake_vehicle_can::SteeringFeedback{frame}); +} + +// ============================================================================ +// J1939 Protocol Parsing Tests +// ============================================================================ + +TEST_CASE("J1939 static constant parsing") +{ + // PDU2 broadcast message + REQUIRE(fake_j1939_can::EngineData::IsJ1939 == true); + REQUIRE(fake_j1939_can::EngineData::Pgn == 61444); + REQUIRE(fake_j1939_can::EngineData::DefaultPriority == 3); + REQUIRE(fake_j1939_can::EngineData::SourceAddress == 0); + REQUIRE(fake_j1939_can::EngineData::IsPduBroadcast == true); + + // Another PDU2 broadcast message + REQUIRE(fake_j1939_can::VehicleSpeed::IsJ1939 == true); + REQUIRE(fake_j1939_can::VehicleSpeed::Pgn == 65265); + REQUIRE(fake_j1939_can::VehicleSpeed::DefaultPriority == 6); + REQUIRE(fake_j1939_can::VehicleSpeed::IsPduBroadcast == true); + + // PDU1 destination-specific message + REQUIRE(fake_j1939_can::TorqueCommand::IsJ1939 == true); + REQUIRE(fake_j1939_can::TorqueCommand::Pgn == 51712); + REQUIRE(fake_j1939_can::TorqueCommand::DefaultPriority == 6); + REQUIRE(fake_j1939_can::TorqueCommand::IsPduBroadcast == false); + // PDU1 messages have a default destination address from the DBC + REQUIRE(fake_j1939_can::TorqueCommand::DefaultDestinationAddress == 0xFF); + + // BrakeCommand is a PDU1 message with SourceAddress=0x25 (Changes how we parse ID, so test it specifically) + REQUIRE(fake_j1939_can::BrakeCommand::IsJ1939 == true); + REQUIRE(fake_j1939_can::BrakeCommand::Pgn == 51200); // 0xC800 + REQUIRE(fake_j1939_can::BrakeCommand::DefaultPriority == 3); + REQUIRE(fake_j1939_can::BrakeCommand::SourceAddress == 0x25); // Specific SA: 37 + REQUIRE(fake_j1939_can::BrakeCommand::IsPduBroadcast == false); + REQUIRE(fake_j1939_can::BrakeCommand::DefaultDestinationAddress == 0x10); // DA: 16 + + // Non-J1939 message + REQUIRE(fake_vehicle_can::Heartbeat::IsJ1939 == false); +} + +TEST_CASE("J1939 PGN matching logic for PDU1 and PDU2 messages") +{ + SECTION("PDU2 - Broadcast PGNs") + { + // Test different priority and source address variations with same PGN + // Original ID + REQUIRE(fake_j1939_can::EngineData::matchesPgn(0x0CF00400)); + // Different source address (SA=0x0B) + REQUIRE(fake_j1939_can::EngineData::matchesPgn(0x0CF0040B)); + // Different source address (SA=0xFF) + REQUIRE(fake_j1939_can::EngineData::matchesPgn(0x0CF004FF)); + // Different priority (priority=6 instead of 3) + REQUIRE(fake_j1939_can::EngineData::matchesPgn(0x18F00400)); + // Different priority (priority=0) + REQUIRE(fake_j1939_can::EngineData::matchesPgn(0x00F00400)); + // Different EFF/RTR/ERR bits + REQUIRE(fake_j1939_can::EngineData::matchesPgn(0xECF00400)); + + // Test different PGNs + // Different PS (PS=0x05 instead of 0x04 -> PGN 61445) + REQUIRE_FALSE(fake_j1939_can::EngineData::matchesPgn(0x0CF00500)); + // Different PS (PS=0x00 -> PGN 61440) + REQUIRE_FALSE(fake_j1939_can::EngineData::matchesPgn(0x0CF00000)); + // Different PF (PF=0xF1 instead of 0xF0 -> PGN 61700) + REQUIRE_FALSE(fake_j1939_can::EngineData::matchesPgn(0x0CF10400)); + // Different Data Page (DP=1 -> PGN 126980) + REQUIRE_FALSE(fake_j1939_can::EngineData::matchesPgn(0x0DF00400)); + // Different Reserved/EDP bit (R=1 -> PGN 192516) + REQUIRE_FALSE(fake_j1939_can::EngineData::matchesPgn(0x0EF00400)); + // Completely different PGN (VehicleSpeed PGN 65265) + REQUIRE_FALSE(fake_j1939_can::EngineData::matchesPgn(0x18FEF100)); + // TorqueCommand PGN (PDU1, PGN 51712) + REQUIRE_FALSE(fake_j1939_can::EngineData::matchesPgn(0x18CAFF00)); + } + + SECTION("PDU1 destination-specific PGNs") + { + // Test different priority, source address, and destination variations with same PGN + // Original ID + REQUIRE(fake_j1939_can::TorqueCommand::matchesPgn(0x18CAFF00)); + // Different source address (SA=0x05) + REQUIRE(fake_j1939_can::TorqueCommand::matchesPgn(0x18CAFF05)); + // Different destination address (PS=0x0A instead of 0xFF) + // For PDU1, PS is destination, NOT part of PGN + REQUIRE(fake_j1939_can::TorqueCommand::matchesPgn(0x18CA0A00)); + // Different destination (PS=0x00 = broadcast) + REQUIRE(fake_j1939_can::TorqueCommand::matchesPgn(0x18CA0000)); + // Both different destination and source + REQUIRE(fake_j1939_can::TorqueCommand::matchesPgn(0x18CA1234)); + // Different priority (priority=3 instead of 6) + REQUIRE(fake_j1939_can::TorqueCommand::matchesPgn(0x0CCAFF00)); + + // Test different PGNs + // Different PF (PF=0xCB instead of 0xCA -> PGN 51968) + REQUIRE_FALSE(fake_j1939_can::TorqueCommand::matchesPgn(0x18CBFF00)); + // Different PF (PF=0xC9 -> PGN 51456) + REQUIRE_FALSE(fake_j1939_can::TorqueCommand::matchesPgn(0x18C9FF00)); + // Different Data Page (DP=1 -> PGN 117248) + REQUIRE_FALSE(fake_j1939_can::TorqueCommand::matchesPgn(0x19CAFF00)); + // Different Reserved/EDP bit (R=1 -> PGN 182784) + REQUIRE_FALSE(fake_j1939_can::TorqueCommand::matchesPgn(0x1ACAFF00)); + // Completely different PGN (EngineData PGN 61444) + REQUIRE_FALSE(fake_j1939_can::TorqueCommand::matchesPgn(0x0CF00400)); + // VehicleSpeed PGN 65265 (PDU2) + REQUIRE_FALSE(fake_j1939_can::TorqueCommand::matchesPgn(0x18FEF100)); + } +} + +TEST_CASE("Ensure J1939 constructor accepts different source address") +{ + can_frame frame{}; + // EngineData from SA=0x0B (different from DBC's SA=0x00) + frame.can_id = 0x0CF0040B | CAN_EFF_FLAG; + frame.can_dlc = 8; + memset(frame.data, 0, 8); + // engine_rpm = 1000 (0x03E8) little-endian at bits 0-15 + frame.data[0] = 0xE8; + frame.data[1] = 0x03; + // engine_load = 75 at bits 16-23 + frame.data[2] = 75; + + fake_j1939_can::EngineData msg{frame}; + REQUIRE(msg.engine_rpm == 1000); + REQUIRE(msg.engine_load == 75); +} + +// Test that it will accept message routed to different destination addresses +// than what is in the DBC for PDU1 messages. I believe this is the correct behavior +// as just an observer on the bus. +TEST_CASE("J1939 PDU1 constructor accepts different destination") +{ + can_frame frame{}; + // TorqueCommand to destination 0x0A (PS=0x0A), from SA=0x05 + frame.can_id = 0x18CA0A05 | CAN_EFF_FLAG; + frame.can_dlc = 8; + // torque_request = 200 at bits 0-7 + frame.data[0] = 200; + + fake_j1939_can::TorqueCommand msg{frame}; + REQUIRE(msg.torque_request == 200); +} + +TEST_CASE("Mixed J1939 and Standard DBC Files") +{ + // Check that EngineAuxStatus shows up as J1939 in the non-J1939 DBC + REQUIRE(fake_vehicle_can::EngineAuxStatus::IsJ1939); + + // Check that BatteryStatus shows up as not J1939 in the J1939 DBC + REQUIRE_FALSE(fake_j1939_can::BatteryStatus::IsJ1939); +} + +// ============================================================================ +// CANHandler Routing Tests +// ============================================================================ + +// Helper to build a J1939 CAN ID with custom source address +static uint32_t make_j1939_id(uint32_t base_id, uint8_t source_address) +{ + return ((base_id & ~0xFF) | source_address) | CAN_EFF_FLAG; +} + +// Helper to build a J1939 PDU1 CAN ID with custom source and destination +static uint32_t make_j1939_pdu1_id(uint32_t base_id, uint8_t dest_address, uint8_t source_address) +{ + return ((base_id & ~0xFFFF) | (dest_address << 8) | source_address) | CAN_EFF_FLAG; +} + +// --- Standard CAN Routing Tests --- + +TEST_CASE("CANHandler Standard CAN - Basic routing") +{ + using fake_vehicle_can::SteeringFeedback; + + dbc_gen_cpp::CANHandler handler; + int received_value = 0; + bool callback_invoked = false; + + handler.set_handler([&](const SteeringFeedback & msg) { + callback_invoked = true; + received_value = msg.angle; + }); + + // Create and send a frame + SteeringFeedback msg{42}; + can_frame frame = msg; + + bool result = handler.handle(frame); + + REQUIRE(result == true); + REQUIRE(callback_invoked == true); + REQUIRE(received_value == 42); +} + +TEST_CASE("CANHandler Standard CAN - Multiple handlers") +{ + using fake_vehicle_can::Heartbeat; + using fake_vehicle_can::SpeedFeedback; + using fake_vehicle_can::SteeringFeedback; + + dbc_gen_cpp::CANHandler handler; + + int heartbeat_count = 0; + float speed_value = 0; + int steering_value = 0; + + handler.set_handler([&](const Heartbeat &) { heartbeat_count++; }); + handler.set_handler([&](const SpeedFeedback & msg) { speed_value = msg.speed; }); + handler.set_handler([&](const SteeringFeedback & msg) { steering_value = msg.angle; }); + + // Send each message type + handler.handle(Heartbeat{}); + handler.handle(Heartbeat{}); + + SpeedFeedback speed_msg{}; + speed_msg.speed = 5.5f; + handler.handle(speed_msg); + + handler.handle(SteeringFeedback{30}); + + REQUIRE(heartbeat_count == 2); + REQUIRE(speed_value == 5.5f); + REQUIRE(steering_value == 30); +} + +TEST_CASE("CANHandler Standard CAN - Unregistered ID returns false") +{ + dbc_gen_cpp::CANHandler handler; + + // Don't register any handlers + can_frame frame{}; + frame.can_id = 100; // SpeedFeedback ID, but no handler registered + frame.can_dlc = 4; + memset(frame.data, 0, 8); + + bool result = handler.handle(frame); + REQUIRE(result == false); +} + +TEST_CASE("CANHandler Standard CAN - Handler replacement") +{ + using fake_vehicle_can::SteeringFeedback; + + dbc_gen_cpp::CANHandler handler; + + int first_callback_count = 0; + int second_callback_count = 0; + + // Register first handler + handler.set_handler([&](const SteeringFeedback &) { first_callback_count++; }); + + handler.handle(SteeringFeedback{10}); + REQUIRE(first_callback_count == 1); + REQUIRE(second_callback_count == 0); + + // Replace with second handler + handler.set_handler([&](const SteeringFeedback &) { second_callback_count++; }); + + handler.handle(SteeringFeedback{20}); + REQUIRE(first_callback_count == 1); // Should not increase + REQUIRE(second_callback_count == 1); +} + +TEST_CASE("CANHandler Standard CAN - Extended frame flag handling") +{ + using fake_vehicle_can::ExtendedDiagnostic; + + dbc_gen_cpp::CANHandler handler; + bool callback_invoked = false; + uint16_t received_code = 0; + + handler.set_handler([&](const ExtendedDiagnostic & msg) { + callback_invoked = true; + received_code = msg.diagnostic_code; + }); + + // Create extended frame message + ExtendedDiagnostic msg{}; + msg.diagnostic_code = 0x1234; + msg.fault_count = 3; + can_frame frame = msg; + + // Verify frame has EFF flag + REQUIRE((frame.can_id & CAN_EFF_FLAG) != 0); + + bool result = handler.handle(frame); + REQUIRE(result == true); + REQUIRE(callback_invoked == true); + REQUIRE(received_code == 0x1234); +} + +// --- J1939 Routing Tests --- + +TEST_CASE("CANHandler J1939 - Basic PGN routing") +{ + using fake_j1939_can::EngineData; + + dbc_gen_cpp::CANHandler handler; + bool callback_invoked = false; + double received_rpm = 0; + + handler.set_handler([&](const EngineData & msg) { + callback_invoked = true; + received_rpm = msg.engine_rpm; + }); + + // Create J1939 message + EngineData msg{}; + msg.engine_rpm = 2500; + msg.engine_load = 75; + can_frame frame = msg; + + bool result = handler.handle(frame); + REQUIRE(result == true); + REQUIRE(callback_invoked == true); + REQUIRE(received_rpm == 2500); +} + +TEST_CASE("CANHandler J1939 - Different source addresses route to same handler") +{ + using fake_j1939_can::EngineData; + + dbc_gen_cpp::CANHandler handler; + int callback_count = 0; + + handler.set_handler([&](const EngineData &) { callback_count++; }); + + // Create base frame + EngineData msg{}; + msg.engine_rpm = 1000; + msg.engine_load = 50; + can_frame frame = msg; + + // Send from different source addresses + frame.can_id = make_j1939_id(EngineData::Id, 0x00); + REQUIRE(handler.handle(frame) == true); + + frame.can_id = make_j1939_id(EngineData::Id, 0x0B); + REQUIRE(handler.handle(frame) == true); + + frame.can_id = make_j1939_id(EngineData::Id, 0xFF); + REQUIRE(handler.handle(frame) == true); + + frame.can_id = make_j1939_id(EngineData::Id, 0x2A); + REQUIRE(handler.handle(frame) == true); + + REQUIRE(callback_count == 4); +} + +TEST_CASE("CANHandler J1939 - PDU1 routes with different destination addresses") +{ + using fake_j1939_can::BrakeCommand; + + dbc_gen_cpp::CANHandler handler; + int callback_count = 0; + + handler.set_handler([&](const BrakeCommand &) { callback_count++; }); + + // Create base frame + BrakeCommand msg{}; + msg.brake_pressure = 50.0; + msg.brake_mode = 1; + can_frame frame = msg; + + // Send to different destination addresses (PDU1: PS field is destination) + // BrakeCommand base ID: 0x0CC81025 (DA=0x10, SA=0x25) + frame.can_id = make_j1939_pdu1_id(BrakeCommand::Id, 0x10, 0x25); // Original + REQUIRE(handler.handle(frame) == true); + + frame.can_id = make_j1939_pdu1_id(BrakeCommand::Id, 0x05, 0x25); // Different dest + REQUIRE(handler.handle(frame) == true); + + frame.can_id = make_j1939_pdu1_id(BrakeCommand::Id, 0xFF, 0x25); // Broadcast dest + REQUIRE(handler.handle(frame) == true); + + frame.can_id = make_j1939_pdu1_id(BrakeCommand::Id, 0x00, 0x30); // Different dest and source + REQUIRE(handler.handle(frame) == true); + + REQUIRE(callback_count == 4); +} + +TEST_CASE("CANHandler J1939 - PDU2 broadcast routing") +{ + using fake_j1939_can::VehicleSpeed; + + dbc_gen_cpp::CANHandler handler; + int callback_count = 0; + double last_speed = 0; + + handler.set_handler([&](const VehicleSpeed & msg) { + callback_count++; + last_speed = msg.speed; + }); + + // VehicleSpeed is PDU2 (PF >= 240) + REQUIRE(VehicleSpeed::IsPduBroadcast == true); + + VehicleSpeed msg{100}; + can_frame frame = msg; + + // Test with different source addresses + frame.can_id = make_j1939_id(VehicleSpeed::Id, 0x00); + REQUIRE(handler.handle(frame) == true); + + frame.can_id = make_j1939_id(VehicleSpeed::Id, 0xFE); + REQUIRE(handler.handle(frame) == true); + + REQUIRE(callback_count == 2); + REQUIRE(last_speed == 100); +} + +TEST_CASE("CANHandler J1939 - Multiple PGN handlers") +{ + using fake_j1939_can::BrakeCommand; + using fake_j1939_can::EngineData; + using fake_j1939_can::VehicleSpeed; + + dbc_gen_cpp::CANHandler handler; + + double engine_rpm = 0; + double vehicle_speed = 0; + double brake_pressure = 0; + + handler.set_handler([&](const EngineData & msg) { engine_rpm = msg.engine_rpm; }); + handler.set_handler([&](const VehicleSpeed & msg) { vehicle_speed = msg.speed; }); + handler.set_handler([&](const BrakeCommand & msg) { brake_pressure = msg.brake_pressure; }); + + // Send each message type + EngineData engine_msg{}; + engine_msg.engine_rpm = 3000; + engine_msg.engine_load = 80; + handler.handle(static_cast(engine_msg)); + + handler.handle(static_cast(VehicleSpeed{65})); + + BrakeCommand brake_msg{}; + brake_msg.brake_pressure = 25.5; + brake_msg.brake_mode = 2; + handler.handle(static_cast(brake_msg)); + + REQUIRE(engine_rpm == 3000); + REQUIRE(vehicle_speed == 65); + REQUIRE(brake_pressure == 25.5); +} + +TEST_CASE("CANHandler With Mixed Standard and J1939 Messages") +{ + using fake_j1939_can::BatteryStatus; // Standard CAN in J1939 DBC + using fake_j1939_can::EngineData; // J1939 + + dbc_gen_cpp::CANHandler handler; + + double battery_voltage = 0; + double engine_rpm = 0; + + handler.set_handler([&](const BatteryStatus & msg) { battery_voltage = msg.battery_voltage; }); + handler.set_handler([&](const EngineData & msg) { engine_rpm = msg.engine_rpm; }); + + // Verify BatteryStatus is standard CAN + REQUIRE(BatteryStatus::IsJ1939 == false); + // Verify EngineData is J1939 + REQUIRE(EngineData::IsJ1939 == true); + + // Send standard CAN message + BatteryStatus battery_msg{}; + battery_msg.battery_voltage = 12.5; + battery_msg.battery_soc = 80; + REQUIRE(handler.handle(static_cast(battery_msg)) == true); + + // Send J1939 message + EngineData engine_msg{}; + engine_msg.engine_rpm = 1500; + engine_msg.engine_load = 60; + REQUIRE(handler.handle(static_cast(engine_msg)) == true); + + REQUIRE(battery_voltage == 12.5); + REQUIRE(engine_rpm == 1500); +} + +TEST_CASE("CANHandler, Ensure Standard CAN lookup happens before J1939") +{ + // This tests that if a standard CAN handler is registered, it takes priority + // over J1939 PGN matching for frames that happen to have the same raw ID + + using fake_vehicle_can::ExtendedDiagnostic; + + dbc_gen_cpp::CANHandler handler; + bool extended_handler_called = false; + + handler.set_handler([&](const ExtendedDiagnostic &) { extended_handler_called = true; }); + + // ExtendedDiagnostic uses extended frames but is NOT J1939 + REQUIRE(ExtendedDiagnostic::IsJ1939 == false); + + ExtendedDiagnostic msg{}; + msg.diagnostic_code = 100; + msg.fault_count = 1; + can_frame frame = msg; + + // The frame has EFF flag, but should be handled by standard CAN lookup + // because ExtendedDiagnostic is registered in the standard handlers map + bool result = handler.handle(frame); + + REQUIRE(result == true); + REQUIRE(extended_handler_called == true); +} + +TEST_CASE("CANHandler Ensure Non-extended frame skips J1939 lookup") +{ + using fake_j1939_can::EngineData; + + dbc_gen_cpp::CANHandler handler; + bool j1939_handler_called = false; + + handler.set_handler([&](const EngineData &) { j1939_handler_called = true; }); + + // Create a standard frame (no EFF flag) with an ID that could be + // misinterpreted if J1939 lookup wasn't gated by EFF flag check + can_frame frame{}; + frame.can_id = 0x100; // Standard CAN ID, no EFF flag + frame.can_dlc = 8; + memset(frame.data, 0, 8); + + bool result = handler.handle(frame); + + // Should return false (no standard handler for 0x100) + // and J1939 handler should NOT be called because EFF flag is not set + REQUIRE(result == false); + REQUIRE(j1939_handler_called == false); +} + +TEST_CASE("CANHandler Ensure Empty handler returns false") +{ + dbc_gen_cpp::CANHandler handler; + // No handlers registered + + // Test with standard CAN frame + can_frame std_frame{}; + std_frame.can_id = 100; + std_frame.can_dlc = 4; + memset(std_frame.data, 0, 8); + REQUIRE(handler.handle(std_frame) == false); + + // Test with J1939 frame + can_frame j1939_frame{}; + j1939_frame.can_id = 0x0CF00400 | CAN_EFF_FLAG; // EngineData PGN + j1939_frame.can_dlc = 8; + memset(j1939_frame.data, 0, 8); + REQUIRE(handler.handle(j1939_frame) == false); +} + +TEST_CASE("CANHandler routes messages with 0 length payload") +{ + SECTION("Standard CAN message with 0 length payload") + { + using fake_vehicle_can::EmptyMessage; + + dbc_gen_cpp::CANHandler handler; + bool callback_invoked = false; + + handler.set_handler([&](const EmptyMessage &) { callback_invoked = true; }); + + EmptyMessage msg{}; + can_frame frame = msg; + + REQUIRE(handler.handle(frame) == true); + REQUIRE(callback_invoked == true); + } + + SECTION("J1939 message with 0 length payload") + { + using fake_j1939_can::EmptyJ1939Message; + + dbc_gen_cpp::CANHandler handler; + bool callback_invoked = false; + + handler.set_handler([&](const EmptyJ1939Message &) { callback_invoked = true; }); + + EmptyJ1939Message msg{}; + can_frame frame = msg; + + // Verify it's treated as J1939 + REQUIRE(EmptyJ1939Message::IsJ1939 == true); + REQUIRE((frame.can_id & CAN_EFF_FLAG) != 0); + + REQUIRE(handler.handle(frame) == true); + REQUIRE(callback_invoked == true); + } +} diff --git a/test_dbc_gen_cpp/test/test_dbc_gen_cpp/catch2_compat.hpp b/test_dbc_gen_cpp/test/test_dbc_gen_cpp/catch2_compat.hpp new file mode 100644 index 0000000..59ca3ca --- /dev/null +++ b/test_dbc_gen_cpp/test/test_dbc_gen_cpp/catch2_compat.hpp @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +// Include the appropriate Catch2 header depending on whether Catch2 v3 or v2 +// is available, and make Catch::Approx available under the unqualified name +// `Approx` used throughout the tests. +#if __has_include() + #include + #include +using Catch::Approx; +#elif __has_include() + #include +#else + #error "Catch2 headers not found. Please install Catch2 (v2 or v3)." +#endif