CAN Bus Simulation with CANopen Advanced

Warning The miniHIL CANOpen abstraction described on this page is currently only available on request and not part of the default miniHIL release. Contact us for more information.

Motivation

In embedded development, it is often necessary to simulate CAN bus nodes, especially when testing larger systems where real hardware is not yet available or is difficult to access. This How-To demonstrates how to set up a basic CAN bus simulation using miniHIL infrastructure and a CANopen communication model.

Pre-requisites

  • miniHIL platform ready

  • CAN bus wiring established (physical connection)

  • A connected Device Under Test DUT that is connected with the simulated CAN bus

CAN: Physical connection miniHIL

Basic Concepts

CAN

The Controller Area Network (CAN) is a robust vehicle bus standard designed to allow microcontrollers and devices to communicate with each other without a host computer. It uses message-based communication and supports multi-master configurations.

CANopen

CANopen is a higher-layer protocol based on CAN, mainly designed for embedded networking applications. It defines communication protocols (PDO, SDO, NMT) and a device description (Object Dictionary).

Basic CANopen concepts:

  • NMT (Network Management): Manages the state of the nodes in the network (Operational, Pre-operational, Stopped).

  • PDO (Process Data Object): Used for real-time data exchange between nodes. PDOs can be transmitted without confirmation.

  • SDO (Service Data Object): Used for configuration and accessing the object dictionary. SDOs require confirmation and are used for non-time-critical data exchange.

  • Object Dictionary: A data structure that defines the parameters and data types of a device. It contains all the information about the device’s capabilities and configuration.

Setting up a CAN Simulation

You will need two actors:

  • AStm32CANService1 for the CAN bus simulation

  • Simulated CANopen node (e.g., GenericCANNode.room)

A basic setup is show below:

ActorClass ACanSimulation {
	Interface {
		Port deviceCtrl: PGenericCANNodeControl
	}
	Structure {
		ActorRef can: AStm32CANService1
		ActorRef canStarter: ACanStarter
		ActorRef deviceSim: AGenericCanNode

		Binding canStarter.canCtrl and can.ctl
		Binding deviceSim.canBus and can.bus
		Binding canStarter.device and deviceSim.fct
		Binding deviceSim.simCtrl and deviceCtrl
	}
}

For this, you need an actor that starts the simulation, which could look like this:

	ActorClass ACanStarter {
		Interface {
			conjugated Port canCtrl: PCANCtrl
			conjugated Port deviceSim: PGenericCANNodeControl
		}
		Structure {
			external Port canCtrl
			external Port deviceSim
		}
		Behavior {
			StateMachine {
				State state0
				Transition init0: initial -> state0 {
					action '''
						DCanParams params;
						DCanParams_init(&params);
						params.baudRate_kbs = 125;
						params.baudRate_kbs_fd = 125;
						canCtrl.open(&params);'''
				}
				State finished
				Transition tr0: state0 -> finished {
					triggers {
						<opened: canCtrl>
					}
					action '''
						deviceSim.boot(10);
					'''
				}
			}
		}
	}

The ACanStarter actor is responsible for opening the CAN bus and starting the simulation. It uses the PCANCtrl port to control the CAN bus and the PGenericCANNodeControl port to boot the simulation.

The Simulated CANopen Node

Each node typically consists of:

  • Heartbeat Producer (sending regular heartbeat messages)

  • CANopen Handler (processing NMT messages)

  • SDO Server (handling SDO requests)

  • PDO Server (sending/receiving PDO messages)

Example Node Structure (AGenericCanNode)

RoomModel actors.GenericCANNode {

	import etrice.api.types.*
	import actors.canopen.base.ASDOServer
	import actors.canopen.base.ACanOpenHeartbeatProducerService
	import actors.canopen.base.ACanOpenHandler
	import actors.canopen.base.APDOServer
	import actors.canopen.base.PCanOpenHeartbeatProducerCtrl
	import actors.canopen.base.PCanOpenCtrl
	import actors.canopen.base.PCanOpenSDOServerConfig
	import actors.canopen.base.PCanOpenSDOEvt
	import actors.canopen.base.PPDOServerCtrl
	import actors.ECMotor.PECMotorMovementSimCtrl
	import etrice.api.logger.PLogger
	import can.api.CANService.PCANData
	import actors.canopen.base.etMemory
	import actors.canopen.base.CanOpenOD


	ActorClass AGenericCANNode {
		Interface {
			conjugated Port canBus[4]: PCANData
			Port fct: PGenericCANNodeControl
		}
		Structure {
			usercode1 '''
				// Start of generated header file
				#include "GenericCANNodeOD.h"
				#include <string.h>
				#include "canopen/CanOpen.h"
				#include "canopen/od/CanOpenOD.h"
				#include "base/etMemory_VariableSize.h"
			'''
			ActorRef sdoServer: ASDOServer

			ActorRef heartbeatProducer: ACanOpenHeartbeatProducerService

			ActorRef canOpenHandler: ACanOpenHandler

			ActorRef pdoServer: APDOServer

			Binding canOpenHandler.status and heartbeatProducer.statusIn
			Binding canOpenHandler.status and sdoServer.status
			Binding canOpenHandler.status and pdoServer.status

			conjugated Port heatbeatCtrl: PCanOpenHeartbeatProducerCtrl
			conjugated Port handlerCtrl: PCanOpenCtrl
			conjugated Port sdoServerCtrl: PCanOpenSDOServerConfig
			conjugated Port pdoServerCtrl: PPDOServerCtrl

			SAP logger: PLogger

			Binding heatbeatCtrl and heartbeatProducer.fct
			Binding sdoServerCtrl and sdoServer.cfg
			Binding pdoServerCtrl and pdoServer.ctrl
			Binding handlerCtrl and canOpenHandler.fct
			Binding canBus and canOpenHandler.canBus
			Binding canBus and sdoServer.canBus
			Binding canBus and heartbeatProducer.canBus
			Binding canBus and pdoServer.canBus
			external Port fct

			 // Attributes
			Attribute odMemory: etMemory ref
			Attribute od: CanOpenOD
			Attribute initSuccessful: boolean = "false"
			Attribute canOpenNodeId: uint8 = "0"

			Attribute enableLogging: boolean = "true"
		}
		Behavior {
			StateMachine {
				State UnConfigured
				State Configured
				Transition init0: initial -> UnConfigured
				Transition tr0: UnConfigured -> Configured {
					triggers {
						<boot: fct>
					}
					action '''
						canOpenNodeId = transitionData;

						odMemory = genericCANNodeId_getOdMemory();
						if (genericCANNodeId_initOD(&od, odMemory, canOpenNodeId)) {
						    initSuccessful = true;
						}

						if (initSuccessful) {
						    DSDOServerConfig cfg;
						    cfg.canOpenNodeID = transitionData;
						    cfg.canOpenObjectDirectory = &od;
						    sdoServerCtrl.configure(&cfg);
						    pdoServerCtrl.setNodeID(canOpenNodeId);
						    pdoServerCtrl.setOd(&od);

						    CanOpenOD_entry* entry = CanOpenOD_getEntry(&od, 0x1008);
						    uint8_t* deviceNamePtr = (uint8_t*)CanOpenOD_getValueFromEntry(entry, 0);
						    snprintf((char*)deviceNamePtr, 150, "GenericNode %d", canOpenNodeId);

						    entry = CanOpenOD_getEntry(&od, 0x1000);
						    uint32_t* deviceTypePtr = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0);
						    *deviceTypePtr = 12345;

						    entry = CanOpenOD_getEntry(&od, 0x1018);
						    uint32_t* identityObject_1 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 1);
						    uint32_t* identityObject_2 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 2);
						    uint32_t* identityObject_3 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 3);

						    *identityObject_1 = 11;
						    *identityObject_2 = 12;
						    *identityObject_3 = 13;

						    entry = CanOpenOD_getEntry(&od, 0x3201);
						    uint32_t* genericObject_0x01 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x01);
						    uint32_t* genericObject_0x02 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x02);
						    uint32_t* genericObject_0x03 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x03);
						    uint32_t* genericObject_0x04 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x04);
						    uint32_t* genericObject_0x05 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x05);
						    uint32_t* genericObject_0x06 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x06);
						    uint32_t* genericObject_0x07 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x07);
						    uint32_t* genericObject_0x08 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x08);
						    uint32_t* genericObject_0x09 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x09);
						    uint32_t* genericObject_0x0A = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x0A);
						    uint32_t* genericObject_0x0B = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x0B);
						    uint32_t* genericObject_0x0C = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x0C);
						    uint32_t* genericObject_0x0D = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x0D);
						    uint32_t* genericObject_0x0E = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x0E);
						    uint32_t* genericObject_0x0F = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x0F);
						    uint32_t* genericObject_0x10 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x10);
						    uint32_t* genericObject_0x11 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x11);
						    uint32_t* genericObject_0x12 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x12);
						    uint32_t* genericObject_0x13 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x13);
						    uint32_t* genericObject_0x14 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x14);
						    uint32_t* genericObject_0x15 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x15);
						    uint32_t* genericObject_0x16 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x16);
						    uint32_t* genericObject_0x17 = (uint32_t*)CanOpenOD_getValueFromEntry(entry, 0x17);
						    *genericObject_0x01 = 0x01;
						    *genericObject_0x02 = 0x02;
						    *genericObject_0x03 = 0x03;
						    *genericObject_0x04 = 0x04;
						    *genericObject_0x05 = 0x05;
						    *genericObject_0x06 = 0x06;
						    *genericObject_0x07 = 0x07;
						    *genericObject_0x08 = 0x08;
						    *genericObject_0x09 = 0x09;
						    *genericObject_0x0A = 0x0A;
						    *genericObject_0x0B = 0x0B;
						    *genericObject_0x0C = 0x0C;
						    *genericObject_0x0D = 0x0D;
						    *genericObject_0x0E = 0x0E;
						    *genericObject_0x0F = 0x0F;
						    *genericObject_0x10 = 0x10;
						    *genericObject_0x11 = 0x11;
						    *genericObject_0x12 = 0x12;
						    *genericObject_0x13 = 0x13;
						    *genericObject_0x14 = 0x14;
						    *genericObject_0x15 = 0x15;
						    *genericObject_0x16 = 0x16;
						    *genericObject_0x17 = 0x17;

						}

						heatbeatCtrl.setNodeID(transitionData);
						heatbeatCtrl.enable(1000);
						handlerCtrl.start(transitionData);'''
				}
			}
		}
	}

	ProtocolClass PGenericCANNodeControl {
		incoming {
			Message boot(uint8)
		}
		outgoing {
		}
	}
}
CANopen: Structure of a simulated CANopen node

Setting up the Object Dictionary

Each simulated node has an Object Dictionary (OD) containing:

  • Static information (Device Name, Vendor ID, etc.)

  • Configuration parameters

  • Application-specific process data

A simple example for setting up an Object Dictionary can be found in GenericCANNodeOD.c in the genericCANNodeId_initOD function.

CanOpenOD_VAR deviceType = (CanOpenOD_VAR) memory->alloc(memory, sizeof(CanOpenOD_VAR));
deviceType->attributes.data.readable = 1;
deviceType->attributes.data.writable = 0;
deviceType->dataLength_bytes = 4;
deviceType->dataPtr = memory->alloc(memory, 4);

...

od->entries[0].odIndex = 0x1000;
od->entries[0].type = CanOpenOD_entryType_VAR;
od->entries[0].entry = (void*)deviceType;

In GenericCanNodeOD.c:

  • genericCANNodeId_getOdMemory(): returns a pointer to the memory manager for the Object Dictionary.

  • genericCANNodeId_initOD(): initializes the Object Dictionary with the given node ID and memory manager.

  • You can add entries such as device type, identity objects, hardware/software version, etc.

Note GenericCANNodeOD.c must define the expected OD entries used in the node. You can create it manually or generate it based on an EDS file.

The content of individual entries in the Object Dictionary can be initialized during the boot transition, as shown in GenericCANNode.room.

To adapt the OD for your specific CANopen node:

  1. Change the device-specific fields like device name, hardware/software version

  2. Add or remove variables, arrays, or records as required.

  3. Optionally map PDOs if real-time data exchange is needed.

Mapping PDOs

To map an entry to a PDO:

  1. Use canOpenPDO_addPDOToOD() to create and register a new PDO (Usually in GenericCANNodeOD.c in the genericCANNodeId_initOD function).:

CanOpenPdoOdEntries addedPDOEntries;
uint8_t success = 0;

// === TPDO 1A00: Maps 0x2031:00 === ------> buttons
if (canOpenPDO_addPDOToOD(memory, od, 0x190, CanOpenPdoType_TPDO, 1, &addedPDOEntries) == 0) {
    CanOpenPDOMapping map = { .index = 0x1000, .subIndex = 0, .length = 1 };
    success |= CanOpenOD_setValueInEntry(addedPDOEntries.mappingEntry, 1, &map, 4, 1, od); // Note: valueLength = 4 always here
    if (success != 0) return 0;
} else {
    return 0;
}

Important notes when mapping PDOs:

  • When mapping from a record, the first subindex starts at 1, not 0.

  • The last parameter valueLength in CanOpenOD_setValueInEntry() must always be set to 4 (when mapping PDOs) because it refers to the size of the CanOpenPDOMapping struct, not the size of the actual data (e.g. 1 byte for a uint8).

Running the Simulation

Once everything is wired and configured:

  • Start the simulation

  • The node will enter "Pre-Operational state"

  • It will respond to NMT, SDO and LSS commands as per CANopen

Triggering PDOs

After setting up the mapping, PDOs must be transmitted either cyclically based on an event. For cyclic PDOs, you can configure a timer-based trigger (e.g. using the APDOServer actor). For event-driven PDOs, you may need to call the pdoServer.triggerPDO() message manually when your data changes.

Extending the Simulation

You can add more simulated nodes by:

  • Instantiating additional "GenericCanOpenNode" actors

  • Setting up individual OD content if needed