Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,50 @@ my_can_socket->send(frame);

```

### Signal Value Maps (Enums)

DBC value tables (`VAL_` lines, and the global `VAL_TABLE_`) map raw signal values
to human-readable states. For every signal that has one, an `enum class` is generated
alongside the message structs, in the library's namespace.

Enums are named `<MessageName>_<SignalName>` (top-level and prefixed, so the same
signal name in different messages never collides). A matching `to_string()` overload
returns the original DBC label text.

Signal fields on the message struct stay as raw physical values (`double`) — the enum
is **additive**, so you opt in by casting when you want the named value:

```c++
#include "my_can_library_name/my_can_library_name.hpp"

// Given a DBC message TransmissionStatus with a signal `gear` whose VAL_ table is
// VAL_ <id> gear 0 "Neutral" 1 "Drive" 2 "Reverse" ... ;
my_can_library_name::TransmissionStatus msg{frame};

auto gear = static_cast<my_can_library_name::TransmissionStatus_gear>(
static_cast<int>(msg.gear));

if (gear == my_can_library_name::TransmissionStatus_gear::REVERSE) {
// ...
}

// to_string() returns the original label from the DBC, handy for logging.
printf("gear = %s\n", my_can_library_name::to_string(gear)); // e.g. "Reverse"
```

Enumerator names come from the DBC label text, uppercased with non-alphanumeric
characters turned into underscores (matching cantools' C `..._CHOICE` macros). A few
labels are adjusted so they remain valid, unique C++ identifiers:

- duplicate labels must become distinct enumerators (C++ forbids repeating a name),
so the raw value is appended: `"Reserved"` at 3 and 4 → `RESERVED_3`, `RESERVED_4`
(cantools does this for its C `..._CHOICE` macros; we keep the same names);
- labels starting with a digit get a leading underscore (`"4wd mode"` → `_4WD_MODE`);
- doubled and trailing underscores are collapsed/stripped
(`"Truck system with fault, stop!"` → `TRUCK_SYSTEM_WITH_FAULT_STOP`).

`to_string()` always returns the unmodified label, regardless of these adjustments.

### CAN Handler - Receive/Subscribe to CAN Messages

A helper class `dbc_gen_cpp::CANHandler` is provided.
Expand Down
103 changes: 102 additions & 1 deletion dbc_gen_cpp/dbc_gen_cpp/generate_cpp.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# SPDX-License-Identifier: Apache-2.0
import argparse
import importlib.resources
import re
from pathlib import Path

from cantools import database
Expand Down Expand Up @@ -37,6 +38,98 @@ def parse_j1939_id(frame_id):
return result


def _sanitize_identifier(name, fallback='X'):
"""Turn arbitrary DBC text (names, labels) into a valid C++ identifier."""
name = re.sub(r'[^0-9a-zA-Z]+', '_', name).strip('_')
if not name:
name = fallback
if name[0].isdigit():
name = '_' + name
return name


def _escape_c_string(text):
"""Escape a DBC label so it is safe inside a C string literal."""
return text.replace('\\', '\\\\').replace('"', '\\"')


def _enum_underlying_type(sorted_values):
"""Smallest stdint type spanning the choice values.

Driven by the values, not the signal's declared type, so float-typed signals
still get an integral base and negatives/high-bit flags always fit.
"""
low, high = sorted_values[0], sorted_values[-1]
signed = low < 0
for bits in (8, 16, 32, 64):
if signed:
if -(1 << (bits - 1)) <= low and high <= (1 << (bits - 1)) - 1:
return f'int{bits}_t'
elif high <= (1 << bits) - 1:
return f'uint{bits}_t'
return 'int64_t' if signed else 'uint64_t'


def build_signal_enums(message, cg_message, used_enum_names):
"""Build an <Message>_<Signal> enum descriptor per signal with VAL_ choices."""
enums = []
for cg_signal in cg_message.cg_signals:
choices = cg_signal.signal.choices
if not choices:
continue

descriptor = f'{message.name}.{cg_signal.signal.name}'
enum_name = _sanitize_identifier(
f'{_sanitize_identifier(message.name)}_{_sanitize_identifier(cg_signal.signal.name)}', fallback='Enum'
)
# Fail loudly for identical names
if enum_name in used_enum_names:
raise ValueError(
f"Generated enum name '{enum_name}' collides between "
f"'{used_enum_names[enum_name]}' and '{descriptor}'. Rename one of the "
f'DBC signals (or its SystemSignalLongSymbol) so the sanitized '
f'<Message>_<Signal> names are unique.'
)
used_enum_names[enum_name] = descriptor

# De-duplicated UPPER_SNAKE names, matching cantools' C ..._CHOICE #defines.
choice_name_by_value = cg_signal.unique_choices
sorted_values = sorted(choice_name_by_value)

enumerator_ident_by_value = {}
used_idents = set()
for raw_value in sorted_values:
enumerator_ident = _sanitize_identifier(choice_name_by_value[raw_value], fallback='VALUE')
if enumerator_ident in used_idents:
# Repeated label (e.g. two "Reserved"): suffix the value to disambiguate.
enumerator_ident = (
f'{enumerator_ident}_{raw_value}' if raw_value >= 0 else f'{enumerator_ident}_n{-raw_value}'
)
if enumerator_ident in used_idents:
raise ValueError(
f"Enumerator '{enumerator_ident}' collides in enum '{enum_name}'. Two DBC "
f'choices sanitize to the same C++ identifier; rename one.'
)
used_idents.add(enumerator_ident)
enumerator_ident_by_value[raw_value] = enumerator_ident

enums.append({
'name': enum_name,
'signal_name': cg_signal.signal.name,
'message_name': message.name,
'underlying_type': _enum_underlying_type(sorted_values),
'enumerators': [
{
'ident': enumerator_ident_by_value[raw_value],
'value': raw_value,
'label': _escape_c_string(str(choices[raw_value])),
}
for raw_value in sorted_values
],
})
return enums


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)
Expand Down Expand Up @@ -68,8 +161,11 @@ def generate_cpp_source(args):
hpp_template = jinja_env.from_string(hpp_template_src)

message_types = []
signal_enums = []
used_enum_names = {}
for message in dbase.messages:
cg_message = CodeGenMessage(message)
signal_enums.extend(build_signal_enums(message, cg_message, used_enum_names))

msg_dict = {
'name': message.name,
Expand All @@ -89,7 +185,12 @@ def generate_cpp_source(args):
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)
hpp_src = hpp_template.render(
library_name=database_name,
messages=message_types,
enums=signal_enums,
c_header=filename_h,
)

with (outdir / filename_hpp).open('w') as f:
f.write(hpp_src)
Expand Down
20 changes: 20 additions & 0 deletions dbc_gen_cpp/dbc_gen_cpp/templates/can.hpp.j2
Original file line number Diff line number Diff line change
Expand Up @@ -123,5 +123,25 @@ struct {{ message.name }}
}
};

{% endfor %}
{% for enum in enums %}
// Value map for {{ enum.message_name }}.{{ enum.signal_name }}
enum class {{ enum.name }} : {{ enum.underlying_type }}
{
{% for v in enum.enumerators %}
{{ v.ident }} = {{ v.value }},
{% endfor %}
};

inline const char * to_string({{ enum.name }} value)
{
switch (value) {
{% for v in enum.enumerators %}
case {{ enum.name }}::{{ v.ident }}: return "{{ v.label }}";
{% endfor %}
default: return "UNKNOWN";
}
}

{% endfor %}
} // namespace {{ library_name }}
8 changes: 8 additions & 0 deletions test_dbc_gen_cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,16 @@ if(BUILD_TESTING)
ament_auto_find_test_dependencies()

find_package(ament_cmake_test REQUIRED)
find_package(ament_cmake_pytest REQUIRED)
find_package(Catch2 REQUIRED)

# Python-level tests for the generator (e.g. the fail-on-ambiguous-name path,
# which cannot be exercised through a generated header).
ament_add_pytest_test(test_enum_generation
test/test_enum_generation.py
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
)

generate_dbc_cpp(fake_vehicle_can
DBC ${CMAKE_CURRENT_SOURCE_DIR}/test/FakeVehicle.dbc
)
Expand Down
2 changes: 2 additions & 0 deletions test_dbc_gen_cpp/package.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

<test_depend>catch2</test_depend>
<test_depend>dbc_gen_cpp</test_depend>
<test_depend>ament_cmake_pytest</test_depend>
<test_depend>python3-pytest</test_depend>

<export>
<build_type>ament_cmake</build_type>
Expand Down
22 changes: 22 additions & 0 deletions test_dbc_gen_cpp/test/FakeVehicle.dbc
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,35 @@ 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

BO_ 102 GearStatus: 1 Vehicle
SG_ gear : 0|8@1+ (1,0) [0|255] "" Computer

BO_ 103 DriveModeStatus: 1 Vehicle
SG_ mode : 0|8@1+ (1,0) [0|255] "" Computer

BO_ 104 MotorStatus: 1 Vehicle
SG_ direction : 0|8@1- (1,0) [-1|1] "" Computer

BO_ 105 SensorFlags: 4 Vehicle
SG_ flags : 0|32@1- (1,0) [0|0] "" 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;
SIG_VALTYPE_ 105 flags : 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";

VAL_ 102 gear 0 "Neutral" 1 "Drive" 2 "Reverse" 3 "Reserved" 4 "Reserved" 5 "4wd mode" 6 "Park!" ;
VAL_ 103 mode 0 "Off" 1 "On" ;
VAL_ 104 direction -1 "Reverse" 0 "Stopped" 1 "Forward" ;
VAL_ 105 flags 1 "Enabled" 2 "Fault" 2147483648 "Calibrating" ;

BA_DEF_ SG_ "SystemSignalLongSymbol" STRING ;
BA_DEF_DEF_ "SystemSignalLongSymbol" "";
BA_ "SystemSignalLongSymbol" SG_ 103 mode "Drive Mode Select";
82 changes: 82 additions & 0 deletions test_dbc_gen_cpp/test/test_dbc_cpp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

#include <cstring>
#include <type_traits>

#include "dbc_gen_cpp/can_handler.hpp"
#include "fake_j1939_can/fake_j1939_can.hpp"
Expand Down Expand Up @@ -954,3 +955,84 @@ TEST_CASE("CANHandler routes messages with 0 length payload")
REQUIRE(callback_invoked == true);
}
}

// ============================================================================
// Signal value-map (enum) generation tests
// ============================================================================
// GearStatus.gear in FakeVehicle.dbc carries a VAL_ table exercising every
// identifier-sanitization path: normal names, a duplicated label ("Reserved"),
// a leading-digit label ("4wd mode") and a punctuation label ("Park!").

TEST_CASE("Generated enum - enumerator values match the DBC value table")
{
using fake_vehicle_can::GearStatus_gear;

REQUIRE(static_cast<int>(GearStatus_gear::NEUTRAL) == 0);
REQUIRE(static_cast<int>(GearStatus_gear::DRIVE) == 1);
REQUIRE(static_cast<int>(GearStatus_gear::REVERSE) == 2);
// Duplicated label "Reserved" is de-duplicated by appending the raw value.
REQUIRE(static_cast<int>(GearStatus_gear::RESERVED_3) == 3);
REQUIRE(static_cast<int>(GearStatus_gear::RESERVED_4) == 4);
// Leading-digit label gets an underscore prefix to stay a valid identifier.
REQUIRE(static_cast<int>(GearStatus_gear::_4WD_MODE) == 5);
// Trailing punctuation is stripped.
REQUIRE(static_cast<int>(GearStatus_gear::PARK) == 6);
}

TEST_CASE("Generated enum - underlying type is the smallest that fits the values")
{
// gear values are 0..6 -> smallest fitting unsigned type.
STATIC_REQUIRE(std::is_same_v<std::underlying_type_t<fake_vehicle_can::GearStatus_gear>, uint8_t>);
}

TEST_CASE("Generated enum - to_string returns the original DBC label")
{
using fake_vehicle_can::GearStatus_gear;

REQUIRE(std::strcmp(fake_vehicle_can::to_string(GearStatus_gear::NEUTRAL), "Neutral") == 0);
REQUIRE(std::strcmp(fake_vehicle_can::to_string(GearStatus_gear::REVERSE), "Reverse") == 0);
// Original label text is preserved verbatim, including punctuation and casing.
REQUIRE(std::strcmp(fake_vehicle_can::to_string(GearStatus_gear::_4WD_MODE), "4wd mode") == 0);
REQUIRE(std::strcmp(fake_vehicle_can::to_string(GearStatus_gear::PARK), "Park!") == 0);
// Both de-duplicated enumerators keep the same source label.
REQUIRE(std::strcmp(fake_vehicle_can::to_string(GearStatus_gear::RESERVED_3), "Reserved") == 0);
REQUIRE(std::strcmp(fake_vehicle_can::to_string(GearStatus_gear::RESERVED_4), "Reserved") == 0);
}

TEST_CASE("Generated enum - signal name with spaces yields a valid enum type")
{
// DriveModeStatus.mode carries a SystemSignalLongSymbol ("Drive Mode Select"),
// which cantools surfaces as the signal name. The spaces must be sanitized out
// of the generated enum type name.
using fake_vehicle_can::DriveModeStatus_Drive_Mode_Select;

REQUIRE(static_cast<int>(DriveModeStatus_Drive_Mode_Select::OFF) == 0);
REQUIRE(static_cast<int>(DriveModeStatus_Drive_Mode_Select::ON) == 1);
REQUIRE(std::strcmp(fake_vehicle_can::to_string(DriveModeStatus_Drive_Mode_Select::ON), "On") == 0);
}

TEST_CASE("Generated enum - negative choice values yield a signed underlying type")
{
// MotorStatus.direction is a signed 8-bit signal with a -1 choice.
using fake_vehicle_can::MotorStatus_direction;

STATIC_REQUIRE(std::is_same_v<std::underlying_type_t<MotorStatus_direction>, int8_t>);
REQUIRE(static_cast<int>(MotorStatus_direction::REVERSE) == -1);
REQUIRE(static_cast<int>(MotorStatus_direction::STOPPED) == 0);
REQUIRE(static_cast<int>(MotorStatus_direction::FORWARD) == 1);
REQUIRE(std::strcmp(fake_vehicle_can::to_string(MotorStatus_direction::REVERSE), "Reverse") == 0);
}

TEST_CASE("Generated enum - float-typed signal with integer flag choices")
{
// Float-typed signal (SIG_VALTYPE_) with integer bit flags: needs an integral
// base wide enough for a 2^31 flag (uint32_t), never `float`.
using fake_vehicle_can::SensorFlags_flags;

STATIC_REQUIRE(std::is_integral_v<std::underlying_type_t<SensorFlags_flags>>);
STATIC_REQUIRE(std::is_same_v<std::underlying_type_t<SensorFlags_flags>, uint32_t>);
REQUIRE(static_cast<uint32_t>(SensorFlags_flags::ENABLED) == 1u);
REQUIRE(static_cast<uint32_t>(SensorFlags_flags::FAULT) == 2u);
REQUIRE(static_cast<uint32_t>(SensorFlags_flags::CALIBRATING) == 2147483648u);
REQUIRE(std::strcmp(fake_vehicle_can::to_string(SensorFlags_flags::CALIBRATING), "Calibrating") == 0);
}
Loading
Loading