25 minute read

In the second tutorial we will write a simple CANopen master and slave using the C++ CANopen application library. The applications will play a form of PDO ping-pong, where each receives a value via PDO and sends it back via another PDO. Trivial, to be sure, but it demonstrates almost everything you need to build a full CANopen application.

We will build up the application incrementally, showing the lines you need to add at each step in the unified diff format. But you can also just download the final master.cpp and slave.cpp.

Like the previous tutorial, we will use the virtual CAN interface on Linux, although the code also shows how to use an IXXAT CAN controller on Windows.

CAN channels and timers

Let’s start simple by just opening a CAN channel. Create the following file:

tutorial.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#include <lely/ev/loop.hpp>
#if _WIN32
#include <lely/io2/win32/ixxat.hpp>
#include <lely/io2/win32/poll.hpp>
#elif defined(__linux__)
#include <lely/io2/linux/can.hpp>
#include <lely/io2/posix/poll.hpp>
#else
#error This file requires Windows or Linux.
#endif
#include <lely/io2/sys/io.hpp>

#if _WIN32
#include <thread>
#endif

using namespace lely;

int
main() {
  // Initialize the I/O library. This is required on Windows, but a no-op on
  // Linux (for now).
  io::IoGuard io_guard;
#if _WIN32
  // Load vcinpl2.dll (or vcinpl.dll if CAN FD is disabled).
  io::IxxatGuard ixxat_guard;
#endif
  // Create an I/O context to synchronize I/O services during shutdown.
  io::Context ctx;
  // Create an platform-specific I/O polling instance to monitor the CAN bus, as
  // well as timers and signals.
  io::Poll poll(ctx);
  // Create a polling event loop and pass it the platform-independent polling
  // interface. If no tasks are pending, the event loop will poll for I/O
  // events.
  ev::Loop loop(poll.get_poll());
  // I/O devices only need access to the executor interface of the event loop.
  auto exec = loop.get_executor();
#if _WIN32
  // Create an IXXAT CAN controller and channel. The VCI requires us to
  // explicitly specify the bitrate and restart the controller.
  io::IxxatController ctrl(0, 0, io::CanBusFlag::NONE, 125000);
  ctrl.restart();
  io::IxxatChannel chan(ctx, exec);
#elif defined(__linux__)
  // Create a virtual SocketCAN CAN controller and channel, and do not modify
  // the current CAN bus state or bitrate.
  io::CanController ctrl("vcan0");
  io::CanChannel chan(poll, exec);
#endif
  chan.open(ctrl);

#if _WIN32
  // Create two worker threads to ensure the blocking canChannelReadMessage()
  // and canChannelSendMessage() used by the IXXAT CAN channel do not hold up
  // the event loop.
  std::thread workers[] = {std::thread([&]() { loop.run(); }),
                           std::thread([&]() { loop.run(); })};
#endif

  // Run the event loop until no tasks remain (or the I/O context is shut down).
  loop.run();

#if _WIN32
  // Wait for the worker threads to finish.
  for (auto& worker : workers) worker.join();
#endif

  return 0;
}

Open a terminal and build tutorial.cpp with

g++ -std=c++14 -Wall -Wextra -pedantic -g -O2 \
    $(pkg-config --cflags liblely-io2) \
    tutorial.cpp -o tutorial \
    $(pkg-config --libs liblely-io2)

The pkg-config lines expand into the compiler options needed to find and link to the I/O library and its dependencies.

If you test the program by running

./tutorial

it should exit immediately without showing anything. Our main() function creates an event loop, which runs until no tasks remain. But since we haven’t submitted any tasks yet, it stops right away.

If, instead, you see the following error:

terminate called after throwing an instance of 'std::system_error'
  what():  CanController: No such device
Aborted (core dumped)

you need to create the vcan0 interface (click here for instructions). And if you see

terminate called after throwing an instance of 'std::system_error'
  what():  CanController: Operation not permitted
Aborted (core dumped)

the transmit queue length is most likely too small. Increase it with

sudo ip link set vcan0 down
sudo ip link set vcan0 txqueuelen 1000
sudo ip link set vcan0 up

To ensure proper blocking behavior when sending CAN frames with SocketCAN, the I/O library sets the socket send buffer to its smallest possible value, while increasing the transmit queue length of the network interface to at least 128 (see section 3.4 in https://rtime.felk.cvut.cz/can/socketcan-qdisc-final.pdf for why this is necessary). However, changing the transmit queue length requires the CAP_NET_ADMIN capability, which our application does not have. Hence the “Operation not permitted” error.

A CANopen device not only requires access to a CAN channel, but also to a system timer. So let’s create one:

tutorial.cpp
--- a/tutorial.cpp
+++ b/tutorial.cpp
@@ -9,6 +9,7 @@
 #error This file requires Windows or Linux.
 #endif
 #include <lely/io2/sys/io.hpp>
+#include <lely/io2/sys/timer.hpp>

 #if _WIN32
 #include <thread>
@@ -36,6 +37,9 @@
   ev::Loop loop(poll.get_poll());
   // I/O devices only need access to the executor interface of the event loop.
   auto exec = loop.get_executor();
+  // Create a timer using a monotonic clock, i.e., a clock that is not affected
+  // by discontinuous jumps in the system time.
+  io::Timer timer(poll, exec, CLOCK_MONOTONIC);
 #if _WIN32
   // Create an IXXAT CAN controller and channel. The VCI requires us to
   // explicitly specify the bitrate and restart the controller.

Clean shutdown

Right now, our application still terminates immediately. But once we turn it into a CANopen device, it will read and process CAN frames indefinitely. The only way to terminate it then will be by killing the process. It would be much nicer, however, to be able to perform a clean shutdown. We can do this by creating a signal handler, which starts the shutdown process when the user presses Ctrl+C or the process receives the termination signal:

tutorial.cpp
--- a/tutorial.cpp
+++ b/tutorial.cpp
@@ -9,6 +9,7 @@
 #error This file requires Windows or Linux.
 #endif
 #include <lely/io2/sys/io.hpp>
+#include <lely/io2/sys/sigset.hpp>
 #include <lely/io2/sys/timer.hpp>

 #if _WIN32
@@ -54,6 +55,21 @@
 #endif
   chan.open(ctrl);

+  // Create a signal handler.
+  io::SignalSet sigset(poll, exec);
+  // Watch for Ctrl+C or process termination.
+  sigset.insert(SIGHUP);
+  sigset.insert(SIGINT);
+  sigset.insert(SIGTERM);
+
+  // Submit a task to be executed when a signal is raised. We don't care which.
+  sigset.submit_wait([&](int /*signo*/) {
+    // If the signal is raised again, terminate immediately.
+    sigset.clear();
+    // Perform a clean shutdown.
+    ctx.shutdown();
+  });
+
 #if _WIN32
   // Create two worker threads to ensure the blocking canChannelReadMessage()
   // and canChannelSendMessage() used by the IXXAT CAN channel do not hold up

The signal handler invokes ctx.shutdown(). This function cancels all pending read and write operations submitted to the CAN channel as well as any wait operation submitted to the timer. And since new I/O operations are aborted before they start, it is only a matter of time before all remaining tasks on the event loop have been executed and it stops.

Since we have registered a wait operation with the signal handler, our application no longer terminates immediately, but waits for the user to stop it.

The slave

Now that the I/O services have been created, it’s time to turn our program into an CANopen device. This is the point where the master and slave implementations diverge. Let’s start with the slave.

Just like the previous tutorial, we need an electronic data sheet (EDS) or device configuration file (DCF). We will load this file at runtime to create the object dictionary.

It is also possible to use the DCF-to-C tool to generate a device description in C at compile time. This is useful for embedded devices which lack the resources to parse a text file at runtime. But it does require the user to recompile their program every time the EDS/DCF changes.

Download cpp-slave.eds. This is a simple device description containing two manufacturer-specific objects: 4000 and 4001, both of type UNSIGNED32. The first object is mapped to a Receive-PDO with CAN-ID $NODEID+0x200. The second to a Transmit-PDO with CAN-ID $NODEID+0x180. Both PDOs are synchronous, so the value of object 4001 is sent by the TPDO after the reception of a SYNC message from the master, while object 4000 is updated by the RPDO after the SYNC. The EDS also contains objects for heartbeat consumption (1016) and production (1017).

For the first version of the slave we will simply create an instance of the BasicSlave class, with node-ID 2. Copy tutorial.cpp to slave.cpp and add the following lines:

slave.cpp
--- a/tutorial.cpp
+++ b/slave.cpp
@@ -11,6 +11,7 @@
 #include <lely/io2/sys/io.hpp>
 #include <lely/io2/sys/sigset.hpp>
 #include <lely/io2/sys/timer.hpp>
+#include <lely/coapp/slave.hpp>

 #if _WIN32
 #include <thread>
@@ -55,6 +56,9 @@
 #endif
   chan.open(ctrl);

+  // Create a CANopen slave with node-ID 2.
+  canopen::BasicSlave slave(timer, chan, "cpp-slave.eds", "", 2);
+
   // Create a signal handler.
   io::SignalSet sigset(poll, exec);
   // Watch for Ctrl+C or process termination.
@@ -70,6 +74,10 @@
     ctx.shutdown();
   });

+  // Start the NMT service of the slave by pretending to receive a 'reset node'
+  // command.
+  slave.Reset();
+
 #if _WIN32
   // Create two worker threads to ensure the blocking canChannelReadMessage()
   // and canChannelSendMessage() used by the IXXAT CAN channel do not hold up

To build the slave, we need to link against the C++ CANopen application library (liblely-coapp):

g++ -std=c++14 -Wall -Wextra -pedantic -g -O2 \
    $(pkg-config --cflags liblely-coapp) \
    slave.cpp -o slave \
    $(pkg-config --libs liblely-coapp)

This library depends on the I/O library, so we don’t need to mention it explicitly.

Running

./slave

shows the following output:

NMT: entering reset application state
NMT: entering reset communication state
NMT: running as slave
NMT: entering pre-operational state
NMT: entering operational state

The slave goes operational by itself and then waits for SYNC messages from the master.

If you happened to run

candump vcan0

in another terminal before starting the slave, you would see a single CAN frame:

vcan0  702   [1]  00

This is the boot-up message for node 2. No other frames are sent, because heartbeat production is disabled by default (as is heartbeat consumption).

It is possible create multiple virtual slave devices in the same process by adding another canopen::BasicSlave instance. Note that you need to create a separate io::Timer and io::CanChannel instance for each node. The io::Context, io::Poll, ev::Loop and io::CanController instances can be shared between multiple nodes.

Generating a master DCF

Like the slave, the master needs an EDS or DCF. But unlike the slave, for which we can usually obtain the EDS/DCF from the vendor, we have to create the master DCF ourselves. It is possible to do this by hand, but it is error-prone and a lot of work. Especially if we want to use the “remote PDO mapping” feature (see below). Fortunately, we have a tool for this: dcfgen.

Create the following file:

cpp-tutorial.yml
master:
  node_id: 1
  sync_period: 1000000 # us

slave_2:
  dcf: "cpp-slave.eds"
  node_id: 2

This is a very simple configuration file, but it’s all dcfgen needs. Many more features are supported of course. Download cpp-tutorial.yml for an example showing all the supported options.

Create master.dcf by running

dcfgen -r cpp-tutorial.yml

The -r flag enables “remote PDO mapping”.

The master

The first version of the master is very similar to the slave. We just create an instance of AsyncMaster (with node-ID 1) instead of BasicSlave:

master.cpp
--- a/tutorial.cpp
+++ b/master.cpp
@@ -11,6 +11,7 @@
 #include <lely/io2/sys/io.hpp>
 #include <lely/io2/sys/sigset.hpp>
 #include <lely/io2/sys/timer.hpp>
+#include <lely/coapp/master.hpp>

 #if _WIN32
 #include <thread>
@@ -55,6 +56,12 @@
 #endif
   chan.open(ctrl);

+  // Create a CANopen master with node-ID 1. The master is asynchronous, which
+  // means every user-defined callback for a CANopen event will be posted as a
+  // task on the event loop, instead of being invoked during the event
+  // processing by the stack.
+  canopen::AsyncMaster master(timer, chan, "master.dcf", "", 1);
+
   // Create a signal handler.
   io::SignalSet sigset(poll, exec);
   // Watch for Ctrl+C or process termination.
@@ -70,6 +77,10 @@
     ctx.shutdown();
   });

+  // Start the NMT service of the master by pretending to receive a 'reset
+  // node' command.
+  master.Reset();
+
 #if _WIN32
   // Create two worker threads to ensure the blocking canChannelReadMessage()
   // and canChannelSendMessage() used by the IXXAT CAN channel do not hold up

Some options supported by dcfgen may cause it to generate a master.bin file in addition to master.dcf. This file contains SDO write requests in the “concise DCF” format, and is used by the master to configure the slave during the boot-up process. If such a file is generated, specify its path as the fourth argument to the AsyncMaster constructor. This tutorial does not produce such a file, so we pass an empty string instead.

The fact that this is a master is determined by the least significant bit in object 1F80 (NMT startup) in master.dcf. This bit is automatically set by dcfgen. Because the slave maps two objects to PDOs, so does the master. But we’ll get to that later.

Open a new terminal and build the master:

g++ -std=c++14 -Wall -Wextra -pedantic -g -O2 \
    $(pkg-config --cflags liblely-coapp) \
    master.cpp -o master \
    $(pkg-config --libs liblely-coapp)

Running

./master

shows almost the same output as the slave:

NMT: entering reset application state
NMT: entering reset communication state
NMT: running as master
NMT: entering pre-operational state
NMT: entering operational state

But if you run

candump vcan0

before starting the master, the output is a little different:

vcan0  701   [1]  00
vcan0  000   [2]  82 00

Like the previous tutorial, the first two lines show the boot-up message from the master, followed by the NMT “reset communication” command (82) to all nodes (00). If you still have the slave running in another terminal, you can see that it received the reset command:

NMT: entering reset communication state
NMT: running as slave
NMT: entering pre-operational state
NMT: entering operational state

while candump shows the boot-up message:

vcan0  702   [1]  00

The EDS of the master contains an entry for node 2 in object 1F81 (NMT slave assignment), which means it will try to boot the slave. In this case, the boot-up procedure consists of two SDO upload (= read) requests:

vcan0  602   [8]  40 00 10 00 00 00 00 00
vcan0  582   [8]  43 00 10 00 00 00 00 00
vcan0  602   [8]  40 18 10 01 00 00 00 00
vcan0  582   [8]  43 18 10 01 60 03 00 00

The first request checks the device type (object 1000), the second the vendor-ID (object 1018 sub-index 1) of the slave. Since both values (0 and 0x360) match the expected values in the EDS of the master (objects 1F84 and 1F85, sub-index 2), the master issues the NMT “start remote node” command (01) to node 2:

vcan0  000   [2]  01 02

Both the heartbeat producer (object 1017) and SYNC producer (object 1005 and 1006) of the master are configured to send a message every second. Since the SYNC message triggers the transmission of two PDOs, four frames appear in the output of candump every second:

vcan0  701   [1]  05
vcan0  080   [0]
vcan0  202   [4]  00 00 00 00
vcan0  182   [4]  00 00 00 00

This first line shows that the master is operational (NMT state 05), the second is the SYNC message (CAN-ID 080), the third is the TPDO sent by the master and the fourth the TPDO sent by the slave (see below).

CANopen events

In our application we would like to be notified of and respond to CANopen events, such as a slave being booted or a SYNC message or PDO being sent/received. In the C++ CANopen application library, this can be achieved by overriding the corresponding method, such as OnBoot() or OnSync(). We could do this by creating our own MyMaster class, which inherits from AsyncMaster and overrides the event callbacks we’re interested in. However, that means we have a single method responsible for handling an event for all slaves. When we have an application with several different types of slaves, this quickly becomes unwieldy. Instead, we create a driver class, each instance of which is responsible for a single slave.

The following code shows how to implement a driver and respond to a boot-up event:

master.cpp
--- a/master.cpp
+++ b/master.cpp
@@ -11,14 +11,42 @@
 #include <lely/io2/sys/io.hpp>
 #include <lely/io2/sys/sigset.hpp>
 #include <lely/io2/sys/timer.hpp>
+#include <lely/coapp/fiber_driver.hpp>
 #include <lely/coapp/master.hpp>

+#include <iostream>
 #if _WIN32
 #include <thread>
 #endif

+using namespace std::chrono_literals;
 using namespace lely;

+// This driver inherits from FiberDriver, which means that all CANopen event
+// callbacks, such as OnBoot, run as a task inside a "fiber" (or stackful
+// coroutine).
+class MyDriver : public canopen::FiberDriver {
+ public:
+  using FiberDriver::FiberDriver;
+
+ private:
+  // This function gets called when the boot-up process of the slave completes.
+  // The 'st' parameter contains the last known NMT state of the slave
+  // (typically pre-operational), 'es' the error code (0 on success), and 'what'
+  // a description of the error, if any.
+  void
+  OnBoot(canopen::NmtState /*st*/, char es,
+         const std::string& what) noexcept override {
+    if (!es || es == 'L') {
+      std::cout << "slave " << static_cast<int>(id()) << " booted sucessfully"
+                  << std::endl;
+    } else {
+      std::cout << "slave " << static_cast<int>(id())
+                << " failed to boot: " << what << std::endl;
+    }
+  }
+};
+
 int
 main() {
   // Initialize the I/O library. This is required on Windows, but a no-op on
@@ -62,6 +90,9 @@
   // processing by the stack.
   canopen::AsyncMaster master(timer, chan, "master.dcf", "", 1);

+  // Create a driver for the slave with node-ID 2.
+  MyDriver driver(exec, master, 2);
+
   // Create a signal handler.
   io::SignalSet sigset(poll, exec);
   // Watch for Ctrl+C or process termination.

Running the master while the slave is running in another terminal shows an extra line of output:

slave 2 booted sucessfully

If the slave is not running, nothing extra is shown. Also not an error, since the behavior prescribed by CiA 302-2 (Network management) is to wait indefinitely for optional slaves.

Configuring a slave

To create our driver, we could have inherited from BasicDriver. However, that would require all our event callbacks to be asynchronous or, at the very least, non-blocking. In the case of OnBoot(), where we simply print a log message, that’s not a problem. But if we need to perform an SDO request and wait for the result, we have to be careful that we don’t hold up the event loop. We could solve this with futures (a bit like the promises chaining in JavaScript), but it’s much more convenient and readable to write (pseudo-)blocking code. This is why our driver inherits from FiberDriver. FiberDriver ensures that every event callback runs inside a fiber (or stackful coroutine). Whenever we issue a blocking request, such as an SDO, we simply suspend the fiber until the request completes.

Instead of FiberDriver, we could also inherit from LoopDriver. This class creates a dedicated event loop for each driver, running in a separate thread. Blocking requests are handled by running the event loop again while waiting for the request to complete. For the most part, the result is indistinguishable from using fibers. But the nested event loops can lead to priority inversion and even deadlocks if you’re not careful.

SDOs are commonly used to configure a slave before it becomes operational. In the following code, we show how to use them to configure heartbeat monitoring between the master and slave:

master.cpp
--- a/master.cpp
+++ b/master.cpp
@@ -45,6 +45,53 @@
                 << " failed to boot: " << what << std::endl;
     }
   }
+
+  // This function gets called during the boot-up process for the slave. The
+  // 'res' parameter is the function that MUST be invoked when the configuration
+  // is complete. Because this function runs as a task inside a coroutine, it
+  // can suspend itself and wait for an asynchronous function, such as an SDO
+  // request, to complete.
+  void
+  OnConfig(std::function<void(std::error_code ec)> res) noexcept override {
+    try {
+      // Perform a few SDO write requests to configure the slave. The
+      // AsyncWrite() function returns a future which becomes ready once the
+      // request completes, and the Wait() function suspends the coroutine for
+      // this task until the future is ready.
+
+      // Configure the slave to monitor the heartbeat of the master (node-ID 1)
+      // with a timeout of 2000 ms.
+      Wait(AsyncWrite<uint32_t>(0x1016, 1, (1 << 16) | 2000));
+      // Configure the slave to produce a heartbeat every 1000 ms.
+      Wait(AsyncWrite<uint16_t>(0x1017, 0, 1000));
+      // Configure the heartbeat consumer on the master.
+      ConfigHeartbeat(2000ms);
+
+      // Report success (empty error code).
+      res({});
+    } catch (canopen::SdoError& e) {
+      // If one of the SDO requests resulted in an error, abort the
+      // configuration and report the error code.
+      res(e.code());
+    }
+  }
+
+  // This function is similar to OnConfg(), but it gets called by the
+  // AsyncDeconfig() method of the master.
+  void
+  OnDeconfig(std::function<void(std::error_code ec)> res) noexcept override {
+    try {
+      // Disable the heartbeat consumer on the master.
+      ConfigHeartbeat(0ms);
+      // Disable the heartbeat producer on the slave.
+      Wait(AsyncWrite<uint16_t>(0x1017, 0, 0));
+      // Disable the heartbeat consumer on the slave.
+      Wait(AsyncWrite<uint32_t>(0x1016, 1, 0));
+      res({});
+    } catch (canopen::SdoError& e) {
+      res(e.code());
+    }
+  }
 };

 int
@@ -104,8 +151,12 @@
   sigset.submit_wait([&](int /*signo*/) {
     // If the signal is raised again, terminate immediately.
     sigset.clear();
-    // Perform a clean shutdown.
-    ctx.shutdown();
+    // Tell the master to start the deconfiguration process for all nodes, and
+    // submit a task to be executed once that process completes.
+    master.AsyncDeconfig().submit(exec, [&]() {
+      // Perform a clean shutdown.
+      ctx.shutdown();
+    });
   });

   // Start the NMT service of the master by pretending to receive a 'reset

The OnConfig() event callback follows the boot process flowchart in CiA 302-2. The OnDeconfig() callback is Lely-specific. We have added it to allow graceful shutdown behavior. In this case by disabling heartbeat monitoring, but it can also be used to put a slave in a known safe state before terminating the master.

Instead of writing code to configure the heartbeat producer and consumer, we could also use the heartbeat_producer and heartbeat_consumer options of dcfgen. This would result in a master.bin file containing the same SDO requests (in the “concise DCF” format) that we now wrote by hand. The master would execute these SDO requests right before calling OnConfig(). We omitted that feature in this tutorial so we could show you how to do it by hand. But for a real application it would be better to do as much as possible with dcfgen before overriding OnConfig().

candump shows two extra SDO requests, download (= write) requests in this case, during the boot-up process of the slave:

vcan0  602   [8]  23 16 10 01 D0 07 01 00
vcan0  582   [8]  60 16 10 01 00 00 00 00
vcan0  602   [8]  2B 17 10 00 E8 03 00 00
vcan0  582   [8]  60 17 10 00 00 00 00 00

The first configures the heartbeat consumer (object 1016 sub-index 1) of the slave, the second the heartbeat producer (object 1017). Similarly, after terminating the master with Ctrl+C, two more SDO requests appear, which disable heartbeat monitoring:

vcan0  602   [8]  2B 17 10 00 00 00 00 00
vcan0  582   [8]  60 17 10 00 00 00 00 00
vcan0  602   [8]  23 16 10 01 00 00 00 00
vcan0  582   [8]  60 16 10 01 00 00 00 00

PDO ping-pong

Like the slave, the master has two manufacturer-specific objects of type UNSIGNED32: 2000 and 2200, mapped to an RPDO and TPDO, respectively. The CAN-ID of the RPDO (182) is equal to that of the TPDO of the slave, and the CAN-ID of the TPDO (202) is equal to the RPDO of the slave. So the value of object 4001 on the slave is copied into a PDO with CAN-ID 182, sent by the slave and received by the master after a SYNC message, and copied into object 2000 on the master. Similary, object 2200 on the master is sent to object 4000 on the slave via a PDO with CAN-ID 202 (sent by the master).

Sending a PDO and processing a received PDO only happen after a SYNC (in the case of synchronous PDOs). This means that it takes two SYNC periods for a change on one of the devices to be communicated to the other. For example, if we change object 4001 on the slave, the value is sent after the next SYNC, it is recevied by the master and held in a buffer until the SYNC after that, when it is processed and copied to object 2000 on the master.

To play PDO ping-pong, we change the slave to copy the value of object 4000 to object 4001 every time we receive a PDO. We have to create our own slave class to be able to override the OnWrite() event callback, which is invoked when an object in the object dictionary is modified:

slave.cpp
--- a/slave.cpp
+++ b/slave.cpp
@@ -19,6 +19,24 @@

 using namespace lely;

+class MySlave : public canopen::BasicSlave {
+ public:
+  using BasicSlave::BasicSlave;
+
+ protected:
+  // This function gets called every time a value is written to the local object
+  // dictionary by an SDO or RPDO.
+  void
+  OnWrite(uint16_t idx, uint8_t subidx) noexcept override {
+    if (idx == 0x4000 && subidx == 0) {
+      // Read the value just written to object 4000:00, probably by RPDO 1.
+      uint32_t val = (*this)[0x4000][0];
+      // Copy it to object 4001:00, so that it will be sent by the next TPDO.
+      (*this)[0x4001][0] = val;
+    }
+  }
+};
+
 int
 main() {
   // Initialize the I/O library. This is required on Windows, but a no-op on
@@ -57,7 +75,7 @@
   chan.open(ctrl);

   // Create a CANopen slave with node-ID 2.
-  canopen::BasicSlave slave(timer, chan, "cpp-slave.eds", "", 2);
+  MySlave slave(timer, chan, "cpp-slave.eds", "", 2);

   // Create a signal handler.
   io::SignalSet sigset(poll, exec);

Alternatively, we could override the OnSync() method, which is invoked after any received PDOs have been processed.

In the master we do something similar, except that we increment the value before copying it. Otherwise we would never see any change. Also, we don’t directly access the local object dictionary. The fact that object 4000 and 4001 on the slave are mapped to object 2200 and 2000 on the master is an implementation detail we’d rather ignore. Instead, we want to use the object indices of the slave in the code of the master.

This does not seem to be of great benefit in this particular example. However, most CANopen slaves follow a particular device profile. In that case, being able to refer to official, standardized object indices, instead of whatever object they happen to be mapped to on the master, greatly simplifies writing generic drivers.

This “remote PDO mapping” is a Lely-specific feature, implemented in the C++ CANopen application library. It requires the user (or rather dcfgen) to copy the PDO mapping on the slave (objects 1600 and 1A00) to the master (object 5A00 and 5E00, respectively), as well as specifying the origin of the PDOs (objects 5800 and 5C00).

Add the OnRpdoWrite() event callback to the master:

master.cpp
--- a/master.cpp
+++ b/master.cpp
@@ -67,6 +67,10 @@
       // Configure the heartbeat consumer on the master.
       ConfigHeartbeat(2000ms);

+      // Reset object 4000:00 and 4001:00 on the slave to 0.
+      Wait(AsyncWrite<uint32_t>(0x4000, 0, 0));
+      Wait(AsyncWrite<uint32_t>(0x4001, 0, 0));
+
       // Report success (empty error code).
       res({});
     } catch (canopen::SdoError& e) {
@@ -92,6 +96,23 @@
       res(e.code());
     }
   }
+
+  // This function gets called every time a value is written to the local object
+  // dictionary of the master by an RPDO (or SDO, but that is unlikely for a
+  // master), *and* the object has a known mapping to an object on the slave for
+  // which this class is the driver. The 'idx' and 'subidx' parameters are the
+  // object index and sub-index of the object on the slave, not the local object
+  // dictionary of the master.
+  void
+  OnRpdoWrite(uint16_t idx, uint8_t subidx) noexcept override {
+    if (idx == 0x4001 && subidx == 0) {
+      // Obtain the value sent by PDO from object 4001:00 on the slave.
+      uint32_t val = rpdo_mapped[0x4001][0];
+      // Increment the value and store it to an object in the local object
+      // dictionary that will be sent by TPDO to object 4000:00 on the slave.
+      tpdo_mapped[0x4000][0] = ++val;
+    }
+  }
 };

 int

We also reset the values of objects 4000 and 4001 in OnConfig(), so we start with a clean slate each time.

When running both applications, candump shows

vcan0  080   [0]
vcan0  202   [4]  00 00 00 00
vcan0  182   [4]  00 00 00 00
...
vcan0  080   [0]
vcan0  202   [4]  00 00 00 00
vcan0  182   [4]  00 00 00 00
...
vcan0  080   [0]
vcan0  202   [4]  01 00 00 00
vcan0  182   [4]  00 00 00 00
...
vcan0  080   [0]
vcan0  202   [4]  01 00 00 00
vcan0  182   [4]  00 00 00 00
...
vcan0  080   [0]
vcan0  202   [4]  01 00 00 00
vcan0  182   [4]  01 00 00 00
...
vcan0  080   [0]
vcan0  202   [4]  01 00 00 00
vcan0  182   [4]  01 00 00 00
...
vcan0  080   [0]
vcan0  202   [4]  02 00 00 00
vcan0  182   [4]  01 00 00 00
...
vcan0  080   [0]
vcan0  202   [4]  02 00 00 00
vcan0  182   [4]  01 00 00 00

where we’ve filtered out the heartbeat messages.

Object 2200 on the master (sent via PDO 202) and 4001 on the slave (sent via PDO 182) start out at 0. The master receives the 0 from object 4001 after the second SYNC, when it increments the value of object 2200. So only on the third SYNC do we see a non-zero value. This value is processed by the slave after the fourth SYNC and sent back to the master after the fifth. All in all, it takes four SYNC periods for an object to be incremented.

This concludes the C++ tutorial. There are many more CANopen events that may need to be handled in a non-trivial application, but they all follow the same pattern shown here.

Updated: