This post is part of the CNC Mill Concept hub. It covers the firmware architecture for the distributed sensor nodes: the design invariants, the layered software stack, the YAML-driven CAN map and code generation workflow, the type-safe CanManager transport layer, and the testing strategy.
The type-safe CAN transport layer concept described here has already been implemented in the CAN transport layer post.
Note: this section is work in progress. Code snippets are conceptual and for reference only.
Fig. 1 shows the full software stack from user application down to hardware.
Shared across all sensor PCBs.
Responsibilities:
init()update()hasDataChanged()sensor.update();
if(sensor.hasDataChanged())
{
auto msg = sensor.getProcessed();
canManager.send(msg);
}
CAN manager:
Message::idMessage::dlcserialize()deserialize()The CAN manager will later be templated on node identity:
using Node = Nodes::ECU;
CanManager<Node> canManager;
Node identity will be defined in the CAN map YAML and code-generated. Future specialization will allow:
Implementation is deferred.
Only message types registered in the CAN map may be transmitted.
template<typename T>
concept CanMessage = IsCanMessage<T>::value;
CAN manager API:
template<CanMessage Message>
void send(const Message& msg);
If a message is not registered, compilation fails. Any change to field layout in YAML modifies the generated header and forces downstream recompilation.
The CAN map is the single source of truth for all messages on the CAN-FD bus. Messages are declared in YAML and code is generated. Messages are grouped by logical node namespace.
can: can0
version: 1
namespaces:
- name: SensorNode
messages:
- name: StrainData
id: 0x201
dlc: 8
fields:
- { name: fx, type: int32, bits: 16 }
- { name: fy, type: int32, bits: 16 }
- { name: fz, type: int32, bits: 16 }
- { name: valid, type: uint8, bits: 1 }
The YAML file defines: output header name, version, namespaces, message ID, DLC, field names, bit width, type, optional scale (float), and optional enum values.
Total bits are tightly packed. Required bytes:
required_bytes = ceil(total_bits / 8)
Validation rule:
required_bytes == dlc
Unused bits inside the final byte are allowed. Unused bytes are not allowed.
The CAN map defines all nodes explicitly. YAML must contain a nodes list and a subscribers list per message.
nodes:
- ECU
- SensorNode
- Diagnosis
namespaces:
- name: SensorNode
messages:
- name: StrainData
id: 0x201
dlc: 7
subscribers:
- ECU
- Diagnosis
Rules:
subscribers is mandatorynodes listFuture generator extension will use this to produce node-specific filter tables, dispatch specialization, and compile-time elimination of unused message handlers.
Generator produces can0.hpp:
namespace SensorNode {
struct StrainData {
static constexpr uint16_t id = 0x201;
static constexpr uint8_t dlc = 8;
#ifdef CANMAP_INCLUDE_MESSAGE_NAMES
static constexpr const char* name = "SensorNode::StrainData";
#endif
int32_t fx; // packed: 16 bits
int32_t fy; // packed: 16 bits
int32_t fz; // packed: 16 bits
uint8_t valid; // packed: 1 bit
};
}
The struct represents physical values only. Packing is handled separately.
YAML:
- name: temperature
type: float
bits: 16
scale: 0.1
Generated struct:
float temperature; // packed: 16 bits, scale: 0.1
static constexpr float temperature_scale = 0.1f;
Serialization:
int32_t raw =
static_cast<int32_t>(
std::round(msg.temperature / temperature_scale));
packSigned<offset,bits>(data, raw);
Float is never transmitted as IEEE-754. It is transported as a scaled integer.
YAML:
- name: state
type: uint8
bits: 3
enum:
0: Idle
1: Running
2: Error
Generated struct:
enum class State : uint32_t {
Idle = 0,
Running = 1,
Error = 2,
};
State state;
Serialization uses the underlying integer value.
Generator produces serialize() and deserialize():
template<>
inline void serialize(const Message& msg, uint8_t* data);
template<>
inline void deserialize(Message& msg, const uint8_t* data);
All packing uses pack_utils.hpp. Properties:
reinterpret_cast
Generator also produces can0.dbc, including message definitions, signal scaling, enum value mappings, and a CAN map version comment. Used for SavvyCAN or other tools.
The CAN manager is the transport layer between application logic and FDCAN. It is strictly transport-only.
It does not:
Public API:
template<CanMessage Message>
void send(const Message& msg);
Transmit sequence:
Message satisfies CanMessage concept.Message::id.Message::dlc.serialize(msg, frame.data).template<CanMessage Message>
void CanManager::send(const Message& msg)
{
modm::can::Message frame(
static_cast<uint32_t>(Message::id),
Message::dlc);
frame.setExtended(false);
serialize(msg, frame.data);
transmit(frame);
}
The CAN manager never touches payload fields directly.
Reception is type-safe and compile-time constrained:
deserialize() reconstructs typed message.Future implementation will use compile-time node specialization:
template<typename Node>
struct NodeDispatch;
template<>
struct NodeDispatch<Nodes::ECU>
{
static void handle(const SensorNode::StrainData& msg);
static void handle(const Diagnosis::Request& msg);
};
CAN manager will forward deserialized messages to:
NodeDispatch<Node>::handle(msg);
This avoids runtime switch statements, dynamic dispatch, and branch-heavy receive logic. Only messages subscribed by the node will generate handlers. No raw buffer handling is exposed to the application layer.
Raw frame APIs must remain private.
Fig. 2 shows the CanManager stack from user application down to hardware, with the YAML-to-generated-code flow and the send/receive data flows.
msg.fx = 123can.send(msg)can.processMessages()can.send(msg)can.processMessages()onMessage(msg) handlerWhat the user sees:
SensorNode::StrainData)int32_t, float, uint8_t)send(), processMessages())What is hidden:
modm::can::MessageTesting is mandatory for serialization correctness and protocol stability. Testing is split into compile-time validation, host-side unit tests, and integration tests.
The generator enforces:
ceil(bits/8) == dlc)C++ enforces:
CanMessage concept restrictionAny change to YAML must trigger full recompilation.
Host-side tests verify bit packing correctness, bit offsets, signed extension correctness, scaled float conversion, enum conversion, boundary conditions, and zero initialization of unused bits.
Message msg{};
msg.fx = 123;
uint8_t buffer[Message::dlc];
serialize(msg, buffer);
Message decoded{};
deserialize(decoded, buffer);
assert(decoded.fx == msg.fx);
Round-trip serialization must be lossless within scaling precision.
For each field: minimum value, maximum value, zero, negative (if signed), and float rounding boundary. Float tests must verify:
abs(original - decoded) <= scale
Integration test stub verifies:
Optional:
The CAN map is versioned. Any change to bit width, field order, scaling, or enum definition changes generated headers and forces rebuild. Host tests must run in CI before deployment.
This project (with exceptions) is published under the CC Attribution-ShareAlike 4.0 International License.