Creating a new protocol
Up to now we considered only the way to provide new implementations for existing protocols. The purpose of this tutorial is to explain how to create completely new protocols. We can separate this tutorial into two parts:
- the first part explains generically how to define new protocols, which basically consists in specifycing an API for the protocol and its default interfaces.
- the second part then focuses on the definition of higher level protocols, that are protocols themselves based on lower level
Interfacesand that are thus independent of the lower level hardware.
New protocol definition
New protocols can be defined direcly into the hardiocore package or alteratively into new packages. As a guideline, it is mostly preferable to define new protocols based on IOs into hardiocore and higher level protocols into new packages. Since this first part of the tutorial consider defining a new protocol based on a IO it is preferable to put it direcly into the hardiocore project.
File structure
The protocol definition should follow the convention:
- its
Protocolheader file should be placed into thehardio/protocolsfolder. - its
Interfaceheader file should be placed into thehardio/interfacesfolder. - If a default or partial implementation of the protocol is given then you should place its header file into the
hardio/implfolder.
If we consider a protocol based on an IO then it means the IO itself has a definition which should be located into the hardio/ios folder.
Now as an example we will consider adding the CAN protocol to the hardiocore package. This protocol already belongs to the package but let’s imagine it does not.
The IO
The first thing to do is to define the new IO. In this example it is the CANBus. So we create a file hardio/ios/can.h which contains:
#pragma once
#include <hardio/common/io.h>
#include <string>
namespace hardio {
class CANBus : public IO {
public:
using base_type = CANBus;
CANBus(const CANBus&) = delete;
virtual ~CANBus() = default;
explicit CANBus(std::string_view id);
std::string_view io_name() const override;
};
} // namespace hardioWe first include the hardio/common/io.h header to get access to the IO extension point. Then the class CANBus is declared as extending the class IO. This basically has for consequence that the CAN bus can be instanciated using the add_io function of an IOBoard.
Then you have to declare the base_type of the IO, which is the name of the class itself (CANBus). It is used for technical reasons so simply add it.
The two functions that are really important for this declaration are:
- the constructor that takes an id string as parameter
io_name()wich returns a human readable name representing the type of the IO.
Here is the corresponding source code located in src/core/ios/can.cpp:
#include <hardio/ios/can.h>
#include <pid/static_type_info.h>
namespace hardio {
CANBus::CANBus(std::string_view id) : IO(pid::type_id<CANBus>(), id) {
}
std::string_view CANBus::io_name() const {
return "CANBus";
}
} // namespace hardioio_name() simply returns the "CANBus" string. It is only usefull to generate nice error messages to help the developpers understand where problems occur at execution.
The constructor simply passes two arguments to the IO base class constructor:
- the unique identifier of the class. This is achieved by the call to
pid::type_id<CANBus>()utility function provided by thepid/static_type_info.hheader. - the string (
id) which identifies theCANBusinstance on theIOBoard. Indeed there may be many instances of the sameIOon a givenIOBoardso they need to be discriminated. Furthermore this identifier will be usable by CAN protocol to configure the CAN bus in use. For instance on linux using the Socket CAN API this would be the network interface of the CAN bus, something likecan0.
That’s it, the IO delcaration is quite simple because IO objects are used to identify available IOs on an IOBoard but provide no functionality.
The Protocol
Now its time to do the real job. We want to define the CAN protocol, so we create a file hardio/protocols/can.h which contains:
#pragma once
#include <hardio/common/protocol.h>
namespace hardio {
class CANSlaveInterface;
class CANProtocol : public Protocol {
public:
using base_type = CANProtocol;
virtual ~CANProtocol() = default;
//functions to be defined
CANProtocol();
virtual std::string name() const override;
bool manage_add_interface(
const std::shared_ptr<ProtocolInterface<CANProtocol>>& dev,
std::string& reason);
template <typename... Args>
auto& add_interface(Args&&... args) {
return add_interface<CANSlaveInterface>(std::forward<Args>(args)...);
}
template <typename T, typename... Args>
T& add_interface(Args&&... args) {
return Protocol::add_interface<T>(std::forward<Args>(args)...);
}
//abstract interface of the protocol
virtual std::vector<uint32_t> connected_slaves() const = 0;
virtual bool connect(uint32_t slave) = 0;
virtual bool write_to(uint32_t slave, uint8_t length,
const uint8_t* data) = 0;
virtual bool read_from(uint32_t slave, uint8_t* data,
uint8_t& data_read) = 0;
virtual bool read_from_if(uint32_t slave, uint8_t* data,
uint8_t& data_read) = 0;
};
} // namespace hardioWe first include the hardio/common/protocol.h header to get access to the Protocol extension point. Then the class CANProtocol is declared as extending the class Protocol. This has for consequence to be capable of calling the bind on Interfaces/IOs using CANProtocol as template parameter.
Then you have to declare the base_type of the protocol, which is the name of the class itself (CANProtocol). It is used for technical reasons so simply add it.
Then there are some functions to defined:
- the default constructor
- the
name()function which identifies the protocol. - the two
add_interface()template function variants. Those functions are automatically called whenever a new interface is added to the protocol. The first variant simply forward the call to the second variant using the default interface for the protocol (here the predeclaredCANSlaveInterface). The second one simply forwards the call to theadd_interface()function of the base class (Protocol::add_interface<T>(std::forward<Args>(args)...)). Most of time you should simply follow this pattern, that is simply here to make the template programming system of hardio work. manage_add_interface()overrided function is used to check if an interface instance can be added to the protocol.
Then the second set of functions define the real functionnalities of the protocol. It is up to you to define a correct API depending on the protocol. For instance here we need a function to declare a connection with a slave (connect()), a function to get access to connected slaves (connected_slaves()) and functions to read/write data from/to slaves (write_to, read_from, read_from_if). All those function use uint32_t identifier to uniquely identify CAN slaves. Since they need to be implemented by protocols implementations all those functions need to be pure virtual (i.e. abstract).
Now let’s see what is the source code for the CANProtocol class, located in src/core/protocols/can.cpp:
#include <hardio/protocols/can.h>
#include <hardio/interfaces/can_slave.h>
namespace hardio {
CANProtocol::CANProtocol() : Protocol(pid::type_id<CANProtocol>()) {
}
std::string CANProtocol::name() const {
return "CAN";
}
bool CANProtocol::manage_add_interface(
const std::shared_ptr<ProtocolInterface<CANProtocol>>& dev,
std::string& reason) {
auto specialized_obj = std::static_pointer_cast<CANSlaveInterface>(dev);
auto servs = connected_slaves();
for (auto& s : servs) {
if (s == specialized_obj->target_slave_address()) {
reason = "target slave address " + std::to_string(s) +
" is already used";
return false; // address is already used
}
}
if (not connect(specialized_obj->target_slave_address())) {
reason = "connection with target slave address " +
std::to_string(specialized_obj->target_slave_address()) +
" failed";
return false;
}
return true;
}
} // namespace hardioThe constructor follows same logic that for IO class : it passes the class type identifier to the base class (Protocol). The name() function follows same logic and has same purpose than the io_name function of IO class.
So the main part consists in implementing the manage_add_interface() function. This function is used to check if an interface can be added to the protocol and if not returns a string explaining what the problem is (used for debugging purpose). In the example it does two things:
- first it checks that the slave address is not aready used (using the
connected_slaves()function). - second it checks that the slave is connected (using
connectfunction).
A technical point is the argument dev passed to the function: Its type is generic (std::shared_ptr<ProtocolInterface<CANProtocol>>) due to the way hardio is implemented using templates, it represents ay possible Interface type for the CANProtocol protocol. In order to get access to the real interface for CANProtocol we can safely static cast it to the type CANSlaveInterface and then we can use the functions of the interface (target_slave_address). This require to have previously added the definition of the corresponding interface (#include <hardio/interfaces/can_slave.h>).
One last important point needs to be discussed right now: The Protocol class declares another pure virtual function:
virtual bool can_bind_to(const std::shared_ptr<Interface>& other,
std::string& reason) const = 0;This function is (automatically) used to check if the current protocol instance can be bound to an interface. You can see it as the twin of manage_add_device() for checking the protocol during bind() process. In this example we would for instance like to override it to check if the CANProtocol binds to a CANBus. But we consider that it would be too limitative because all implementations of CANProtocol would then have to bind to a CANBus. Indeed we can imagine for instance that a USB device can give access to a virtual CAN bus, using a speicific API. But if we constraint CANProtocol to bind to a physical CANBus then there is no more way to provide an implementation for CANProtocol: we would thus need to define a new protocol for this specific case. This is obvoiously not optimal if we considered reuse because then the drivers using the CANSlaveinterface would not be compatible with this CAN over USB specific case, which would be a bit annoying.
That is why we decide, as a design choice, to not provide an implementation for the can_bind_to() which makes the CANProtocol far more flexible and adaptable to various situations. The direct pratical consequence is that CANProtocol child classes need to override the can_bind_to() function.
The partial implementation
Considering the previous situation it is possible to directly define the can_bind_to() function in the protocol implementation class. But it is also possible to create intermediate implementation classes in order to mutualize generic aspects. This is what we call partial protocol implementation.
hadiocore provides partial implementation for protocols whose primary intent is to define the can_bind_to() function. Indeed this function is not really implementation related: it is used to define a constraint between hardio concepts. Here is the definition of the NativeCANProtocolBase class, that is a partial implementation of CANProtocol (we merge the header found in hardio/impl/can_protocol_base.h and source in src/core/impl/can_protocol_base.cpp for the sack of readability):
#pragma once
#include <hardio/protocols/can.h>
#include <hardio/ios/can.h>
namespace hardio {
class NativeCANProtocolBase : public CANProtocol {
public:
NativeCANProtocolBase() = default;
NativeCANProtocolBase(const NativeCANProtocolBase&) = delete;
virtual ~NativeCANProtocolBase() = default;
bool can_bind_to(const std::shared_ptr<Interface>& other,
std::string& reason) const override{
if (other->type_id() != pid::type_id<CANBus>()) {
reason = " CAN protocol can be bound to a CAN Bus interface";
return false;
}
return true;
}
};
} // namespace hardioAs any implementation NativeCANProtocolBase extends CANProtocol. It just provides an override of can_bind_to() function. This function’s code simply check that the type of the interface in use (using type_id() function) is CANBus.
NativeCANProtocolBase purpose is to be extended by any implementation class that relies on a CANBus IO directy provided by a IOBOard. Another way to say that is that the protocol implementation will need to use a CAN port provided by the hardware (and managed eihter by a board specific library or by the OS).
The only benefit is to avoid repeating again and again the same implementation of the can_bind_to() function among various implementation classes.
The Interface
The last thing to do once the protocol is defined (and also eventually its partial implementations) is to define its default Interface. Remember that an Interface is a virtual access point provided by protocol used to communicate with one or more device. In the example the interface allows to communicate with a can slave while the CAN master is the IOBoard implementing the CANProtocol.
The header for the CANSlaveInterface is located in hardio/interfaces/can_slave.h
#pragma once
#include <hardio/common/interface.h>
#include <hardio/protocols/can.h>
namespace hardio {
class CANSlaveInterface : public ProtocolInterface<CANProtocol> {
public:
using base_type = CANSlaveInterface;
CANSlaveInterface(uint32_t device_address);
virtual ~CANSlaveInterface() = default;
bool write(uint8_t length, const uint8_t* data);
bool read(uint8_t* data, uint8_t& data_read);
bool read_if(uint8_t* data, uint8_t& data_read);
uint32_t target_slave_address() const;
std::string id() const override;
private:
uint32_t slave_address_;
};
} // namespace hardioTo be declared as a valid interface for the CANProtocol the CANSlaveInterface class extends ProtocolInterface<CANProtocol>. It then defines the base_type type with the class name. Those two elements must be done, they are mandatory to make the hardio system work.
Then the id() function must be overriden, the same way as for a IO. This function is used to uniquely identify the Interface regarding its Protocol, in a similar way an IO defines this function to uniquely identify the IO regarding the IOBoard that provides it.
Since each CAN slave is addressed with a 29 bits integer on a given CAN bus we need to memorize its address (see attribute slave_address_ which is 32 bits long) and we also need to be capable of setting it at slave declaration time, which is achieved by passing the address at CANSlaveInterface object construction time.
The remaining functions define the functionnal API of a CANSlaveInterface: write(), read(), read_if() and target_slave_address(). As a fist remark you can notice that their names need not to be the same as their counterpart in the CANProtocol class.
Now let’s have a look to the class implementation located in src/core/interfaces/can_slave.cpp:
#include <hardio/interfaces/can_slave.h>
namespace hardio {
CANSlaveInterface::CANSlaveInterface(uint32_t address)
: ProtocolInterface<CANProtocol>(),
slave_address_{(address & 0x3FFFFFFF)}
// only consider the 29 rightmost bits of 32 bits buffer (29 bits CAN address)
{
}
bool CANSlaveInterface::write(uint8_t length, const uint8_t* data) {
return protocol()->write_to(target_slave_address(), length, data);
}
bool CANSlaveInterface::read(uint8_t* data, uint8_t& data_read) {
return protocol()->read_from(target_slave_address(), data, data_read);
}
bool CANSlaveInterface::read_if(uint8_t* data, uint8_t& data_read) {
return protocol()->read_from_if(target_slave_address(), data, data_read);
}
uint32_t CANSlaveInterface::target_slave_address() const {
return slave_address_;
}
std::string CANSlaveInterface::id() const {
return std::to_string(slave_address_);
}
} // namespace hardioThe constructor call the parent class constructor then set the slave address from the argument. target_slave_address() and id() implementations are straightforward.
The 3 main communication functions write(), read() and read_if() simply forward their calls to their counterparts in the underlying protocol implementation (using the protocol() accessor), respectively write_to(), read_from() and read_from_if(). Their only utility is to correctly manage addressing by forcing a communication, through the CANProtocol instance, with the CAN slave represented by the CANSlaveInterface instance.
That’s it, the new protocol is defined. The only thing you need is probably a first implementation of this protocol in order to test it. If you are interested there is the LinuxCANProtocol provided by the hardio-linux package that provide an implementation based on Linux CAN Sockets.
Once the definition is made you can write code the usual way:
#include <hardio/interfaces/can_slave.h>
...
IOBoard board;
auto & can_bus = board.add_io<CANBus>("can0").bind<LinuxCANProtocol>();
auto& can_slave1 = can_bus.add_interface(2);
auto& can_slave2 = can_bus.add_interface(18);
//do something with slaves
uint8_t wr_data[25];
uint8_t rd_data[40];
can_slave1.read(rd_data, 10);
can_slave2.read(rd_data, 40);
//do something with data read
...
//write into wr_data
can_slave1.write(wr_data, 25);
...Higher level protocols
Now you know how to define new protocols we finally can focus on defining higher level protocols. They are protocols that use lower level protocols to implement their communication with device but adding additionnal logic on top of those communication means. Remember that such higher level protocols are meaningfull only if the devices you want to communicate with are also using this high level protocol.
As a story arc we take as example the ModbusOverSerial protocol. Modbus is a protocol initially designed for allowing communications between multiple devices over a serial link. The objective compared to I2C or CAN was to simplify implementation of multiple communicating devices in a simple way on top of a widely used serial interface. It is still used in industrial/buildings control applications and also in some embdded systems.
In order to understand the following code you need to have basic knowledge of the modbus protocol. The logic of the ModbusOverSerial protocol is to let the modbus master (the IOBoard) sequentially query its slaves registers in an infinite communication loop. Each slave defines a set of registers that can be either read and/or written by the master, each register being a memory zone. All slaves and the master are connected to the same serial link so each slave receives all requests (reading or writing registers) sent by the master and must so filter these request to only answer/acknowledge to requests that target it. To sum up the master is the active agent of a modbus network and slaves simply react to its requests by sending back either read values for registers read or an aknowledge when register values are written.
The protocol
If you want the real full implementation you can have a look into the hardiocore package, where the two variants of modbus are provided (modbus over serial and modbus over UDP), which slightly differ in their implementation AND that use different lower level interface (serial or UDP). NOTE: this real implementation shows how to get 2 different higher level protocols sharing the same Interface.
The header
But let’s keep it simple for this tutorial, here is the delaration of the protocol:
#pragma once
#include <hardio/protocols/modbus/modbus.h>
#include <hardio/interfaces/serial.h>
#include <thread>
#include <atomic>
...
namespace hardio {
class ModbusSlaveInterface;
class ModbusOverSerialProtocol : public modbus::Modbus, public Protocol {
public:
using base_type = ModbusOverSerialProtocol;
ModbusOverSerialProtocol();
virtual ~ModbusOverSerialProtocol() = default;
std::string name() const override;
bool manage_add_interface(
const std::shared_ptr<ProtocolInterface<ModbusProtocolMaster>>& dev,
std::string& reason);
template <typename... Args>
auto& add_interface(Args&&... args) {
return add_interface<ModbusSlaveInterface>(std::forward<Args>(args)...);
}
template <typename T, typename... Args>
T& add_interface(Args&&... args) {
return Protocol::add_interface<T>(std::forward<Args>(args)...);
}
bool can_bind_to(const std::shared_ptr<Interface>& other,
std::string& reason) const final;
//custom code injecion
bool custom_initialization(std::string& reason,
bool managed_thread = true) final;
void custom_uses(const std::shared_ptr<Interface>& other) final;
void custom_finish() override;
//protocol specific code
void call_threaded_callback();
void add_dialogue(uint8_t slave, const std::function<void()>& dialogue);
protected:
// communication implementation for modbus::Modbus
size_t bus_write(uint8_t slave, uint8_t* buf, size_t length) final;
size_t bus_read(uint8_t slave, modbus::Command cmd, uint8_t* buf,
size_t length, size_t& start_byte,
const std::chrono::duration<double>& timeout) final;
private:
std::weak_ptr<SerialInterface> ser_;
std::thread dialogue_loop_;
std::atomic<bool> end_;
};
}As you can see even the simplified code is significantly more complex than the code of CANProtocol. This is most of time true for higher level protocols because you have to manage everything by hand: addressing, messages exchanges, control flow and so on. Sometimes you may find a library that implements the protocol logic which can really help. In this example the protocol logic is put into modbus::Modbus class that we will not detail in this tutorial. Let’s focus on the hardio related stuff:
First of all we include headers of the modbus::Modbus class and the header of the lower level interface used, in this example the SerialInterface.
Then class declaration follows basically the same logic as previously demonstrated in CANProtocol example (see constructor, name(), manage_add_interface(), add_interface() and can_bind_to()), it just also inherits the modbus::Modbus class that implements the protocol logic. The second part is the most important regarding this tutorial, it contains custom code that needs to be trigerred at specific moment of protocol lifecycle, see: custom_initialization(), custom_uses() and custom_finish(). Third part is protocol specific part and will be discussed later.
In the protected zone of the class, there are the functions bus_write and bus_read that are overrides of the modbus::Modbus class corresponding functions. These functions are used to put in place communication using the lower level interface. The lower level interface itself is memorized using a smart pointer: std::weak_ptr<SerialInterface> ser_. Please remember to always use a weak_ptr to memorize interfaces in order to avoid any memory leak due to cyclic dependency. The thread and the atomic variable are used for control flow management and will be discussed later.
About the Modbus class
You should now have noticed that there is no functionnal API in the ModbusOverSerialProtocol : there is no pure virtual functions as in previous example. First of all, for higher level protocols you can (but you are not forced to) direcly provide an implementation for the functionnal API so there is often no real need to define pure virtual functions simply because the code is generic and does not need to be adapted depending on the hardware/OS. Second, the functionnal API exists and is implemented in the modbus::Modbus class, it is simplified in the following code:
class Modbus {
...
public:
// network control management
void cmd_write_reg16(uint8_t slv, uint16_t regadr, uint16_t nb,
const std::chrono::duration<double>& timeout,
bool if_requested = false);
void cmd_read_reg16(uint8_t slv, uint16_t regadr, uint16_t nb,
const std::chrono::duration<double>& timeout,
bool if_requested = false);
...
// registers access
void slave_read_register16(uint8_t slv, uint16_t regaddr, uint16_t& val,
bool waitforupdate);
void slave_write_register16(uint8_t slv, uint16_t regaddr, uint16_t val,
bool waitforwrite);
...
protected:
// communication implementation : to be specialized
virtual size_t bus_write(uint8_t slave, uint8_t* buf, size_t length)=0;
virtual size_t bus_read(uint8_t slave, modbus::Command cmd, uint8_t* buf,
size_t length, size_t& start_byte,
const std::chrono::duration<double>& timeout)=0;
...
};cmd_write_reg16(), cmd_read_reg16() and other equivalent functions are used to manage the access to the serial link when communication with a slave must be set : they trigger calls to bus_write() and bus_read() functions.
Other functions like slave_read_register16(), slave_write_register16() and other equivalent functions are used to read/write slave registers from the application code. These functions are those that are considered as part of the protocol API.
The implementation
Now let’s see the implementation step by step, starting with the first part:
ModbusOverSerialProtocol::ModbusProtocolMaster()
: Modbus(),
Protocol(pid::type_id<ModbusProtocolMaster>()),
dialogue_loop_{},
end_{false} {
}
std::string ModbusOverSerialProtocol::name() const {
return "Modbus";
}
bool ModbusOverSerialProtocol::manage_add_interface(
const std::shared_ptr<ProtocolInterface<ModbusProtocolMaster>>& dev,
std::string& reason) {
auto interf = std::static_pointer_cast<ModbusSlaveInterface>(dev);
if (interf->address() == 0) {
reason = "address cannot be 0";
return false;
}
if (not this->add_slave(interf->address())) {
reason = "slave address " + std::to_string(interf->address()) +
" already used";
return false;
}
return true;
}
bool ModbusOverSerialProtocol::can_bind_to(
const std::shared_ptr<Interface>& other, std::string& reason) const {
if (other->type_id() == pid::type_id<SerialProtocol>()) {
return true;
}
reason =
"ModbusOverSerial protocol can only be bound to a serial interface";
return false;
}Nothing really new here, it is exacly the same logic as for CANProtocol. We can notice that :
- the address of the slave to be added to the protocol is verified by
manage_add_interface()and the slave is memorized using themodbus::Modbusadd_slavefunction. can_bind_to()checks that the interface the protocol binds to must be a serial interface.
The protected functions are implemented this way:
size_t ModbusOverSerialProtocol::bus_write(uint8_t slave, uint8_t* buf,
size_t length) {
if (not ser_.lock()->write(length, buf)) {
throw modbus::BusError(slave, true);
}
return length;
}
using namespace std::chrono_literals;
size_t ModbusOverSerialProtocol::bus_read(
uint8_t slave, modbus::Command cmd, uint8_t* buf, size_t length,
size_t& start_byte, const std::chrono::duration<double>& timeout) {
size_t n;
pid::TimeReference ref;
ref.reset();
size_t nb_loops = 0;
size_t read_bytes = 0;
size_t read_last_cycle = 0;
size_t required_size_to_read = length;
start_byte = 0;
do {
if (nb_loops++ > 0) {
std::this_thread::sleep_for(READ_WAITING_TIMESTEP);
}
if (not ser_.lock()->read(required_size_to_read - read_bytes,
(uint8_t*)(buf + read_bytes),
read_last_cycle)) {
throw modbus::BusError(slave, false);
}
read_bytes += read_last_cycle;
if (read_bytes > 1) {
if (buf[start_byte] != slave or
(buf[start_byte + 1] &
(static_cast<uint8_t>(modbus::Command::CMD_ERR) |
static_cast<uint8_t>(cmd))) == 0) {
// anbother slave id, answering, maybe this is
// a reponse to an old timed out request
// or a slave answer to an old request with a lot of delay !!
// discarding the adequate amount of data
switch (buf[start_byte + 1]) {
case static_cast<uint8_t>(modbus::Command::READ_HR):
if (read_bytes > 2) {
required_size_to_read +=
buf[start_byte + 2] + 5; // need to read more
start_byte += buf[start_byte + 2] + 5;
}
break;
case static_cast<uint8_t>(modbus::Command::WRITE_MULT): {
required_size_to_read += 8; // need to read more
start_byte += 8;
} break;
}
}
}
} while (read_bytes < required_size_to_read and ref.timestamp() < timeout);
if (read_bytes < required_size_to_read) { // nothing received !!!!
throw modbus::TimeoutException(slave, read_bytes,
required_size_to_read);
}
return read_bytes;
}The bus_write() implementation is straightforward, it simply calls the lower level serial interface write() function.
The bus_read() implementation is more complex because it needs to deal with read errors (timeouts of answers, invalid devices answering to the current request) and waited message length. But finally it consists in using the read() function of the serial interface (see ser_.lock()->read()) to read messages from devices.
Now let’s see the functions that are really new are important when developping higher level protocols:
bool ModbusOverSerialProtocol::custom_initialization(std::string& reason,
bool managed_thread) {
auto it = slaves_.begin();
if (it == slaves_.end()) {
reason = "ModbusOverSerialProtocol has no slave";
return false;
}
++it;
if (it == slaves_.end()) {
reason = "ModbusOverSerialProtocol has no slave";
return false;
}
for (; it != slaves_.end(); ++it) { //
if (not it->second->is_scannable()) {
reason = "ModbusOverSerialProtocol slave " + std::to_string(it->first) +
" has not been initialized";
return false;
}
}
if (managed_thread) {
pid::SyncSignal sync;
dialogue_loop_ = std::thread([this, &sync] {
this->end_ = false;
sync.notify();
while (not this->end_) {
this->call_threaded_callback();
}
});
sync.wait();
}
return true;
}
void ModbusOverSerialProtocol::custom_uses(
const std::shared_ptr<Interface>& other) {
// memorize the serial interface used
ser_ = other->ptr<SerialInterface>();
}
void ModbusOverSerialProtocol::custom_finish() {
if (dialogue_loop_.joinable()) {
// if the protocol was initialized then stop it cleanly
end_ = true;
dialogue_loop_.join();
}
}These 3 functions are called at specific moment of the protocol lifecycle:
custom_initialization()is called when theinitialize()function of the mainIOBoardis called.custom_finish()is called when thefinish()function of the mainIOBoardis called (explicitely or atIOBoarddestruction time).custom_uses()is called when theProtocolhas just been bound to anInterface(usingbind()function).
Lets’ now explain their use in ModbusOverSerialProtocol code.
custom_initialization()is responsible of performing last global checks before the protocol can be used, basically that there are slaves and they are all initialized. The argumentmanaged_threadistruewhenever execution threads must be internally managed by protocols (default behavior). In this case the function create a thread (dialogue_loop_) that puts in place the cyclic communication piloted by the modbus master, which basically consist in infinitely callingcall_threaded_callback()function until end is requested (attributeend_set totrue).custom_finish()stops the infinite loop (if needed) by settingend_totrue.custom_uses()memorizes in the local protocol instance attributeser_the serial interface it is bound to. This is the way a protocol can get back information on this interface at configuration time.
The call_threaded_callback() function is thus an important function, here is its implementation:
void ModbusOverSerialProtocol::call_threaded_callback() {
auto master = slaves_.begin();
// it->second->
auto sl_it = ++master;
for (; sl_it != slaves_.end(); ++sl_it) { //
sl_it->second->call_dialogue(); // call the dialogue function
}
}This function implement the sequential dialogue with each slave in the network. To do this it iterates over the slaves_sequence, that is an attribute provided by the modbus::Modbus class and call their call_dialogue() function. This later function simply forwards calls to a lambda function attached to the slave. This lambda is configured using the add_dialogue() function (see declaration) and its implementation will not be detailed because it is straightforward. We will see later what this lambda contains, for now simply consider it contains registers read/write calls between the master and the slave device.
The interface
Now it is time to write the default interface for modbus protocol, here is the simplified code:
#pragma once
#include <hardio/common/interface.h>
#include <hardio/protocols/modbus.h>
#include <functional>
namespace hardio {
class ModbusSlaveInterface : public ProtocolInterface<ModbusOverSerialProtocol> {
public:
using base_type = ModbusSlaveInterface;
ModbusSlaveInterface(size_t slave_addr);
std::string id() const override;
virtual ~ModbusSlaveInterface() = default;
// functions for configuring the device
void define_registers(uint16_t first_register_addr,
const std::vector<uint16_t>& flag);
void initialized(
const std::function<void()>& dialogue_function);
// functions used to implement dialogue with the device
void cmd_write_reg16(uint16_t register_addr, uint16_t nb_registers,
const std::chrono::duration<double>& timeout);
void cmd_read_reg16(uint16_t register_addr, uint16_t nb_registers,
const std::chrono::duration<double>& timeout,
bool only_if_read_request = false);
// functions used to access device registers
bool read_register16(uint16_t register_addr, uint16_t& value,
bool wait_for_update);
bool write_register16(uint16_t register_addr, uint16_t value,
bool wait_for_write);
...
size_t address() const;
void set_address(uint8_t addr);
...
private:
size_t slave_addr_;
};
} // namespace hardioThe declaration of ModbusSlaveInterface follows same base rules as already explained in CANProtocol tutorial. We can see that the constructor requires an address to be defined for the slave and memorizes it in the slave_addr_ attribute.
As you can see the interface reflects the API of the modbus::Modbus class:
- functions to put in place communication with the slave device (
cmd_write_reg16()andcmd_read_reg16()), their implementation is quite straightforward, it simply pass argument to the protocol corresponding functions, only new thing is that it automatically sets target slave address.
void ModbusSlaveInterface::cmd_write_reg16(
uint16_t register_start_addr, uint16_t nb_registers,
const std::chrono::duration<double>& timeout) {
protocol()->cmd_write_reg16(slave_addr_, register_start_addr, nb_registers,
timeout);
}
void ModbusSlaveInterface::cmd_read_reg16(
uint16_t register_start_addr, uint16_t nb_registers,
const std::chrono::duration<double>& timeout, bool only_if_read_request) {
protocol()->cmd_read_reg16(slave_addr_, register_start_addr, nb_registers,
timeout, only_if_read_request);
}- functions to access slave registers (
read_register16(),write_register16()and other similar functions not shown here). Again the implementation is straightforward and relies on the protocol corresponding functions:
bool ModbusSlaveInterface::read_register16(uint16_t register_addr,
uint16_t& value,
bool wait_for_update) {
try {
protocol()->slave_read_register16(slave_addr_, register_addr, value,
wait_for_update);
} catch (std::exception& e) {
return false;
}
return true;
}
bool ModbusSlaveInterface::write_register16(uint16_t register_addr,
uint16_t value,
bool wait_for_write) {
try {
protocol()->slave_write_register16(slave_addr_, register_addr, value,
wait_for_write);
} catch (std::exception& e) {
return false;
}
return true;
}There is also a specific function used to define the registers provided by the slave device: define_registers().
void ModbusSlaveInterface::define_registers(
uint16_t first_register_addr, const std::vector<uint16_t>& flags) {
// initializing globally the registers
protocol()->initialize_slave_registers(
slave_addr_, flags.size(), first_register_addr,
static_cast<uint16_t>(modbus::RegFlag::WR_ENABLE) |
static_cast<uint16_t>(modbus::RegFlag::RD_ENABLE));
for (auto& flag : flags) {
protocol()->slave_set_register_flags(slave_addr_, first_register_addr++,
flag);
}
}This function creates the registers and then sets their flags to specify if they are readonly or read/write.
The function initialized is used to set the lambda function implementing the communication with the corresponding slave device by calling the add_dialogue() function of the protocol.
void ModbusSlaveInterface::initialized(
const std::function<void()>& dialogue_function) {
protocol()->add_dialogue(slave_addr_, dialogue_function);
}You can notice that again interface definition is a simple task as most of the work has been done in the protocol class.
Using Modbus
Now let’s have a look at how to write user code:
auto& modbus =
board.add_io<SerialBus>("/dev/ttyS0")
.bind<PosixSerialProtocol>(115200)
.add_interface()
.bind<ModbusOverSerialProtocol>();
auto& modbus_dev1 = modbus.add_interface(7);
auto& modbus_dev2 = modbus.add_interface(12);
//defining registers
modbus_dev1.define_registers(1,
{hardio::modbus::READ, hardio::modbus::READ, hardio::modbus::READ,
hardio::modbus::READ, hardio::modbus::RD_WR, hardio::modbus::RD_WR,
hardio::modbus::RD_WR, hardio::modbus::RD_WR});
modbus_dev2.define_registers(1,
{hardio::modbus::READ, hardio::modbus::READ, hardio::modbus::READ,
hardio::modbus::RD_WR, hardio::modbus::RD_WR, hardio::modbus::RD_WR});
//defining dialogue functions
modbus_dev1.initialized([&] {
modbus_dev1.cmd_write_reg16(5, 4, PWRS_MODBUS_STD_TIMOUT);
std::this_thread::sleep_for(PWRS_MODBUS_T_INTERTRAMES);
modbus_dev1.cmd_read_reg16(1, 8, PWRS_MODBUS_STD_TIMOUT);
std::this_thread::sleep_for(PWRS_MODBUS_T_INTERTRAMES);
});
modbus_dev2.initialized([&] {
modbus_dev2.cmd_write_reg16(4, 3, PWRS_MODBUS_STD_TIMOUT);
std::this_thread::sleep_for(PWRS_MODBUS_T_INTERTRAMES);
modbus_dev2.cmd_read_reg16(1, 6, PWRS_MODBUS_STD_TIMOUT);
std::this_thread::sleep_for(PWRS_MODBUS_T_INTERTRAMES);
});
...
//use at runtime
uint16_t reg_val;
//reading value of slave 1 register 2, waiting value to be updated
modbus_dev1.read_register16(2, reg_val, true);
//writing value of slave 1 register 4 and slave 2 register 5, without waiting
modbus_dev1.write_register16(4, 27, false);
modbus_dev2.write_register16(5, 122, false);
...We first create the ModbusOverSerialProtocol protocol and create two ModbusSlaveInterface used to communicate with slave devices with address 7 and 12.
Then the remaining part of the code configures the interfaces in order to enable communication with real devices:
- the first slave has 8 registers starting at address 1, the 4 first being readonly and the 4 remaining being read/write. Its dialogue function first write the value of the 4 last registers before reading all registers values.
- the second slave has 6 registers starting at address 1, the 3 first being readonly and the 3 remaining being read/write. Its dialogue function first write the value of the 3 last registers before reading all registers values.
Please note that the dialogue functions may be more complicated with multiple reading/writing steps and a state machine like behavior, the provided implementation being the typical write everything/read everything behavior.
Once done slaves are configured and the board can be intialized.
Then at runtime the behavior consists in using the interfaces by calling read_register16(), write_register16() and other similar functions to get/set value of slaves’ registers.
Please note that except for the interfaces declaration (e.g. auto& modbus_dev1 = modbus.add_interface(7);) all the remaining code should be put into the slave driver code and so be hidden for the end user. Please refer to the tutorial explaining how to write drivers.
That’s it you now have a general understanding on the way to write new protocols and interfaces.