Skip to content
Merged
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
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,54 @@ 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
**nested inside its message struct**.

The enum type name is the signal name in `PascalCase`, so it is referenced as
`<MessageName>::<SignalName>` (e.g. `TransmissionStatus::Gear`). PascalCasing keeps the
type distinct from the message's raw signal field, which stays `snake_case` (`double gear;`).
Because each enum is scoped to its own struct, the same signal name in different messages
never collides; two signals *within one message* whose names PascalCase to the same
identifier fail generation loudly. A matching `to_string()` overload in the library's
namespace 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
142 changes: 141 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,140 @@ 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 _enum_type_name(signal_name):
"""PascalCase enum type name for a signal, e.g. 'gear' -> 'Gear'.

The enum is nested inside its message struct, so the name only needs to be
unique within that struct. PascalCase keeps it distinct from the snake_case
signal member that shares the same source name (member 'gear' vs type 'Gear').
"""
parts = re.split(r'[^0-9a-zA-Z]+', signal_name)
name = ''.join(p[:1].upper() + p[1:] for p in parts if p)
if not name:
name = 'Enum'
if name[0].isdigit():
name = '_' + name
return name


# Struct-scope names the template already emits; a nested enum must not shadow them.
_RESERVED_STRUCT_NAMES = frozenset({
'Id',
'DataLength',
'IsJ1939',
'IsExtendedFrame',
'Pgn',
'DefaultPriority',
'SourceAddress',
'IsPduBroadcast',
'DefaultDestinationAddress',
'matchesPgn',
})


def build_signal_enums(message, cg_message):
"""Build a nested enum descriptor per signal with VAL_ choices.

Each enum is scoped inside its message struct, referenced as
``<Message>::<Signal>`` (e.g. ``GearStatus::Gear::RESERVED``).
"""
enums = []
# Names already taken inside this struct: the message name (constructor),
# every signal member, and the static members the template emits.
reserved = {message.name, *_RESERVED_STRUCT_NAMES}
reserved.update(cg_signal.snake_name for cg_signal in cg_message.cg_signals)
used_enum_names = {}
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 = _enum_type_name(cg_signal.signal.name)
# Fail loudly on a name clash within the struct (case-sensitive, matching C++).
if enum_name in reserved:
raise ValueError(
f"Generated enum name '{enum_name}' for '{descriptor}' collides with an "
f'existing member of struct {message.name}. Rename the DBC signal (or its '
f'SystemSignalLongSymbol) so its enum type name is unique within the message.'
)
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 so their PascalCase enum names are unique within the message.'
)
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,
'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 @@ -85,11 +220,16 @@ def generate_cpp_source(args):
}
for cg_signal in cg_message.cg_signals
],
'enums': build_signal_enums(message, cg_message),
}
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,
c_header=filename_h,
)

with (outdir / filename_hpp).open('w') as f:
f.write(hpp_src)
Expand Down
24 changes: 24 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 @@ -16,6 +16,16 @@ namespace {{ library_name }}
{% for message in messages %}
struct {{ message.name }}
{
{% for enum in message.enums %}
// Value map for {{ message.name }}.{{ enum.signal_name }}
enum class {{ enum.name }} : {{ enum.underlying_type }}
{
{% for v in enum.enumerators %}
{{ v.ident }} = {{ v.value }},
{% endfor %}
};

{% endfor %}
static constexpr uint32_t Id = {{ message.can_id }};
static constexpr uint8_t DataLength = {{ message.data_length }};

Expand Down Expand Up @@ -123,5 +133,19 @@ struct {{ message.name }}
}
};

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

{% endfor %}
{% 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";
Loading
Loading