COMMS
Template library intended to help with implementation of communication protocols.
|
This page is oriented to client application developers. It provides instructions on how to use protocol defininition after it was developed following How to Define New Custom Protocol instructions. All the examples below will use my_protocol namespace for classes that are expectected to be already defined by the custom protocol implementation.
The protocol definition as well as COMMS library are headers-only. The protocol definition should already include all the required headers from the COMMS library. The client application will have to include only relevant headers from the protocol definition.
All the classes provided by the COMMS library reside in comms namespace and the inner include paths start with "comms/". There may be the case when this library being integrated into existing project that may already define and use its own module (namespace) named comms. In this case it should be possible to rename installation directory (comms to comms2) and to run a script after library installation to replace the following strings with new name:
The COMMS library is intended to be used in embedded systems (including bare metal), which means the library does not use exceptions to report errors. The runtime errors are reported via comms::ErrorStatus return values. All pre- and post-conditions are checked using COMMS_ASSERT() macro. It is, just like regular standard assert(), is compiled in if NDEBUG symbol is not defined. In case the provided condition doesn't hold true, the macro checks whether custom assertion failure behaviour was registered. If yes, the registered custom assertion failure report is invoked, otherwise the standard failure report used by standard assert() macro is used. If COMMS library is used in bare metal environment without standard library, the COMMS_NOSTDLIB symbol should be defined. In this case infinite loop is a default assertion failure report behaviour.
See Custom Assertion Failure Behaviour for details on how to define custom assertion failure behaviour.
The Protocol Definition classes are expected to define all the relevant protocol classes in a way that provides an ability to perform end application specific customization, such as choice of specific data types and/or necessary polymorphic interfaces. Such customization is performed by passing various options defined in comms::option::app namespace to the relevant class definitions. The comms::option::def namespace contains all the types and classes relevant for the protocol definition itself. However, they can also be used by the end application if need arises to introduce such modifications.
The protocol definition is expected to define extendable message interface class pinning only serialisation endian and numeric message ID type (most probably enum).
Such interface class is NOT polymorphic, it defines the following inner types
Note the existence of MsgIdType and MsgIdParamType. When the type used for message ID is simple integral one or enum, these types are equal. However, if some other type is used, such as std::string, then MsgIdParamType is a const-reference to MsgIdType.
The message interface class may be extended with multiple options, which automatically add virtual functions, and hence create polymorphic behaviour relevant to the client application.
In general, all the API functions that are being added to the interface (and described below) use Non-Virtual Interface idiom:
The polymorphic behaviour is exposed via protected virtual functions having the same name, but with Impl suffix.
All the variants of message interface class that are going to be described below are descendants of comms::Message class. Please refer to the documentation of the latter for detailed info on the described functions and their parameters.
When there is a need to be able to polymorphically retrieve message ID, the comms::option::app::IdInfoInterface option needs to be used. Note, that this option requires presence of comms::option::def::MsgIdType (which is expected to be used in protocol definition) to specify type of the message ID in order to work properly.
It adds the following functions:
The usage of comms::option::app::IdInfoInterface without comms::option::def::MsgIdType will be ignored, and getId() as well as getIdImpl() member functions won't be created.
The comms::Message interface class defines comms::Message::hasGetId() static constexpr member function, which may be used at compile time to determine whether the comms::option::app::IdInfoInterface option has been used, i.e. the message interface class defines mentioned earlier functions.
If the implementation requires polymorphic read and process of input messages, the read() operation needs to be added to the interface. It is achieved by using comms::option::app::ReadIterator option to provide a type of the iterator that is going to be used for reading:
As the result the interface class defines the following types and functions:
Please note, that COMMS library doesn't impose any restrictions on how the input data is collected and stored. It is a responsibility of the caller to allocate and maintain the input buffer, while providing only an iterator for read operation.
Also note, that iterator is passed by reference, which allows advancing operator when read operation is performed.
For example:
The comms::Message interface class defines comms::Message::hasRead() static constexpr member function, which may be used at compile time to determine whether the comms::option::app::ReadIterator option has been used, i.e. the message interface class defines mentioned earlier types and functions.
If the implementation requires polymorphic serialisation of the messages and sending them over I/O link, the write() operation needs to be added to the interface. It is achieved by using comms::option::app::WriteIterator option to provide a type of the iterator that is going to be used for writing:
As the result the interface class defines the following types and functions:
Please note, that COMMS library doesn't impose any restrictions on storage type for the output buffer. It is a responsibility of the caller to allocate and maintain the output buffer, while providing only an iterator for write operation. In the example above the output buffer is chosen to be std::vector<std::uint8_t> and the write operation will be performed using push_back() calls on this vector (due to std::back_insert_iterator being chosen as WriteIterator).
Also note, that iterator is passed by reference, which allows advancing operator when write operation is performed. In case the iterator is random-access one, the difference between the initial and its value after the write has been performed can be used to determine amount of bytes that have been written to the buffer.
The comms::Message interface class defines comms::Message::hasWrite() static constexpr member function, which may be used at compile time to determine whether the comms::option::app::WriteIterator option has been used, i.e. the message interface class defines mentioned earlier types and functions.
Sometimes it may be needed to polymorphically retrieve the serialisation length of the message in order to be able to reserve or allocate enough space for output buffer. The COMMS library provides comms::option::app::LengthInfoInterface option that adds length() member function to the interface.
This option adds the following functions to the interface definition:
The comms::Message interface class defines comms::Message::hasLength() static constexpr member function, which may be used at compile time to determine whether the comms::option::app::LengthInfoInterface option has been used, i.e. the message interface class defines mentioned earlier functions.
Sometimes it may be needed to be able to check whether the message contents (fields) have valid values. The COMMS library provides comms::option::app::ValidCheckInterface option that adds valid() member function to the interface:
This option adds the following functions to the interface definition:
The comms::Message interface class defines comms::Message::hasValid() static constexpr member function, which may be used at compile time to determine whether the comms::option::app::ValidCheckInterface option has been used, i.e. the message interface class defines mentioned earlier functions.
When new data arrives on I/O link, it's transport framing is going to be processed (described in detailed in Transport Framing section below). and new message object is going to be created. It's going to be returned as a smart pointer (std::unique_ptr) to the defined interface class (MyMessage). The actual type of this message object needs to be recognised and message properly handled. Using simple switch statement on message ID (returned by getId() interface function) can result in significant amount of boilerplate code, which grows and must be updated every time new message is added to the protocol. The COMMS library provides much better way to dispatch messages to appropriate handler.
The handler class needs to be forward declared and passed to the definition of MyMessage interface via comms::option::app::Handler option.
When this option is used the MyMessage will define the following interface types and functions:
More details about polymorphic dispatching and handling will be provided below in Message Handling section.
The comms::Message interface class defines comms::Message::hasDispatch() static constexpr member function, which may be used at compile time to determine whether the comms::option::app::Handler option has been used, i.e. the message interface class defines mentioned earlier types and functions.
Some communication protocol may define fields that depend on other fields. For example, bits in a bitmask field may be used to define whether some optional fields exist. Or the information about amount of elements in the list to follow may reside in an independent numeric field.
After updating such fields directly, using the interface of the message object, the message contents may end up being in an inconsistent (or invalid) state. There may be a need to polymorphically normalise the state of the message object. The COMMS library provides comms::option::app::RefreshInterface option, that adds refresh() member function to the message interface.
This option adds the following functions to the interface definition:
Note, that the refresh() member function returns boolean value, which is expected to be true in case at least one of the internal fields has been updated, and false if message state remains unchanged.
Also note, that interface provide default implementation of refreshImpl() virtual function. The message object that require proper "refresh" functionality may just override it with proper implementation.
The comms::Message interface class defines comms::Message::hasRefresh() static constexpr member function, which may be used at compile time to determine whether the comms::option::app::RefreshInterface option has been used, i.e. the message interface class defines mentioned earlier functions.
Some applications may require knowledge about and report the name of the received / sent message. The COMMS library provides comms::option::app::NameInterface option, that adds name() member function to the message interface (see comms::Message::name()).
This option adds the following functions to the interface definition:
The comms::Message interface class defines comms::Message::hasName() static constexpr member function, which may be used at compile time to determine whether the comms::option::app::NameInterface option has been used, i.e. the message interface class defines mentioned earlier functions.
By default the comms::Message class defines its destructor as virtual if and only if it exhibits a polymorphic behaviour, i.e. if there is at least one other virtual function defined. There are a couple of ways to change this default behaviour.
All the options introduced above can be used in any order. They can also be repeated multiple times. However, the option that was defined first takes priority over (or overrides) the same option defined later.
For example, the definition below defines WriteIterator to be std::uint8_t*, because it was defined with first comms::option::app::WriteIterator option:
The definition below gives a full interface of all the introduced functions: getId(), read(), write(), dispatch(), length(), valid(), refresh(), and name().
In case no polymorphic interface extension option has been chosen, every message object becomes a simple "data structure" without any v-table "penalty".
The protocol messages are expected to be defined as template classes, receiving at least one template parameter, which specifies the application specific interface class. For example
The interface class that was defined for the application (MyMessage) needs to be passed as TBase template parameter. The defined message class extends comms::MessageBase, which in turn extends provided interface class TBase, which in turn extends (or typedef-s) comms::Message. The inheritance hierarchy may look like this:
Due to the fact that every protocol message class extends comms::MessageBase, the detailed documentation on available member types and functions can be viewed on comms::MessageBase reference page.
All the protocol message classes implement non-virtual functions that may be used to implement polymorphic behavior. These function has the same name as described earlier interface, but start with do* prefix.
Based on the requested polymorphic functionality, the comms::MessageBase class automatically implements virtual *Impl() member functions (but only when needed).
Such architecture allows usage of non-virtual functions when actual type of the message is known. For example
and using polymorphic behaviour when not
Every message has zero or more fields, which are stored in std::tuple as private members of comms::MessageBase. The access to the fields can be obtained using fields() member function (see comms::MessageBase::fields()).
However, every message that has at least one field is expected to use COMMS_MSG_FIELDS_NAMES() (or COMMS_MSG_FIELDS_ACCESS() in older versions) macro to provide names to inner fields.
It is equivalent of having the following types and member functions defined.
As the result every message field can be accessed by index
or by name
In order to continue with the tutorial, it is paramount to understand a concept of fields, which are abstractions around value storage primitives and/or objects, such as integral values, floating point values, strings, arrays, etc.. Every field class is defined in comms::field namespace and exposes predefined interface in order to make template meta-programming as easy as possible. As an example let's take a look at comms::field::IntValue class which is used to define integral value field.
The main things to note are that every field definition class:
Also note that all the member function are NON-virtual, i.e. the field abstractions do not have polymorphic behaviour.
The most important member function to a client application is value(). It allows access to the stored value. Note, that the stored value is accessed by reference. It allows both get and set operations:
Other member functions are of lesser importance to the client application, they are used by the protocol definition itself to properly (de)serialise message contents and provide other useful functionality.
The available fields abstractions are:
Integral value fields are defined using comms::field::IntValue class. Its inner ValueType type is the same as second template parameter. Most integral value fields are defined and used "as-is"
Some field's definitions may use comms::option::def::NumValueSerOffset option, which adds predefined offset to the field before serialising and subtracts it before deserialising. Classic example would be having a "year" information, but serialised as offset from year 2000 with a single byte. Such field may be defined as following:
NOTE, that while serialisation takes only 1 byte, the client application will use full year number without worrying about added / removed offset
Some protocols may exchange floating point values by serialising them as integral ones. For example, multiply the floating point value by 1000 before the serialisation, and upon reception divide the received value by 1000 to get the floating point one. Such fields will be defined using comms::field::IntValue type with using comms::option::def::ScalingRatio option
The inner value of such field is integral one. However, there are comms::field::IntValue::getScaled() and comms::field::IntValue::setScaled() member functions that allow get and set original floating point value without worrying what math operation needs to be performed.
In addition to scaling, the COMMS library also provides an ability to specify units. For example, some protocol may define distance in 1/10 of the millimetres. The definition of such field may look like this
The COMMS library provides a limited set of units conversion functions in comms::units namespace. When using the provided conversion function the application developer doesn't need to remember the original units and/or scaling factor. The COMMS library does all the math. It also prevents (at compile time) usage of wrong conversion functions, say calculating time (milliseconds), when specified units are distance (millimetres).
NOTE, that COMMS library is about the communication protocols and not about unit conversions. The unit conversion functionality is quite basic and limited. If there is a need to use third party unit conversion library, it could be wise to static_assert on assumption for origin units.
or
By default, When comms::field::IntValue field is constructed, the inner value is constructed to be 0. However, the field definition may use comms::option::def::DefaultNumValue option to specify some other value
The enum values are defined using comms::field::EnumValue class. It is very similar to Integral Value Fields. The main difference, that second template parameter as well as inner ValueType type is enum. The enum can be scoped (enum class) or regular (just enum).
Note, that underlying type of the enum dictates default serialisation length.
Bitmasks (or bitsets) are also numeric values where every bit has separate, independent meaning. Such fields are defined using comms::field::BitmaskValue class.
The field definition will use comms::option::def::FixedLength option in order to specify its serialisation length. The inner ValueType type will be calculated automatically and defined as one of the unsigned types: std::uint8_t, std::uint16_t, std::uint32_t, or std::uint64_t. The usage of comms::option::def::BitmaskReservedBits option will mark certain bits as "reserved". It influences only validity check functionality (see comms::field::BitmaskValue::valid()). If any of the reserved bits doesn't have an expected value, the call to valid() member function will return false.
The bitmask field definition is also expected to use COMMS_BITMASK_BITS() and COMMS_BITMASK_BITS_ACCESS() macros to provide names for the bits and generate convenience access function. If bit names are sequential, i.e. start from bit 0, and go up without any holes in the middle, then single COMMS_BITMASK_BITS_SEQ() macro can be used instead achieving the same effect.
is equivalent to defining:
The generated convenience access functions use existing comms::field::BitmaskValue::getBitValue() and comms::field::BitmaskValue::setBitValue() member functions.
It is also possible to set multiple bits at the same time by accessing the stored value directly
Many communication protocols try to pack multiple independent values into a one or several bytes to save traffic on I/O link. For example RS-232 serial port configuration may be defined as following:
3 Bits to configure baud rate:
Baud Rate | Serialisation Value |
---|---|
9600 | 0 |
14400 | 1 |
19200 | 2 |
28800 | 3 |
38400 | 4 |
57600 | 5 |
115200 | 6 |
2 Bits to configure parity:
Parity | Serialisation Value |
---|---|
None | 0 |
Odd | 1 |
Even | 2 |
2 Bits to configure stopBits:
Stop Bits | Serialisation Value |
---|---|
One | 0 |
One and half | 1 |
Two | 2 |
2 Bits to configure flow control:
Flow Control | Serialisation Value |
---|---|
None | 0 |
Hardware | 1 |
Software | 2 |
The field definition will use comms::field::Bitfield and probably look similar to code below
All the member fields of the comms::field::Bitfield are stored internally as std::tuple, as the result the inner ValueType of such field is std::tuple of all member fields and call to comms::field::Bitfield::value() member function will give an access to it.
The field definition is expected to use COMMS_FIELD_MEMBERS_NAMES() macro, which will generate FieldIdx enum as well as convenience access member functions. The code becomes equivalent to:
Accessing the member field value in such setup, such as "baud" may look like this:
There are cases when multiple independent fields need to be bundled into a single field and expose the required interface of reading, writing, calculating length, checking field's contents validity, and bringing field's value into a consistent state. It may be required when a message contains sequence (see Array List Fields) of such bundles/structs. The COMMS library provides comms::field::Bundle field for this purpose. It is quite similar to comms::field::Bitfield described earlier. The difference is that every member field doesn't specify any length in bits, just bytes. For example:
Just like with Bitfield Fields, the inner ValueType type of such field is a std::tuple of member fields, and usage of COMMS_FIELD_MEMBERS_NAMES() has exactly the same effect, i.e. generates inner FieldIdx enum and convenience access member functions:
Some communication protocols may define messages that transmit sequence of similar fields and/or raw data buffers. To make it easier to handle, the COMMS library provides comms::field::ArrayList field which provide a required interface to properly handle such sequences of data. It supports a sequence of raw bytes
as well as using sequence of any fields defined in comms::field namespace
The default storage type (inner ValueType type) of these fields is std::vector of specified type, i.e. it is std::vector<std::uint8_t> in case of MySimpleList and std::vector<MyBundle> in case of MyComplexList. Such default storage type may be unsuitable to certain applications, especially for bare-metal ones. The COMMS library allows changing it using extra options. The protocol definition is expected to provide a way to pass extra options to such fields. It is explained in Application Specific Customisation of Fields section below in more details.
Please pay attention, that in case of non-raw data lists, the inner std::vector contains fields, not values. As the result access to the stored values may require a bit of extra function calls:
Some protocols may define fixed size lists. In such case lists are defined with usage of comms::option::def::SequenceFixedSize option.
Usage of this option just ensures right amount of elements "on the wire" after the field is serialised, but it does NOT automatically resize inner storage vector.
Many protocols have to transfer strings. They are defined using comms::field::String field.
It is very similar to comms::field::ArrayList it terms of value storage, read/write operations, and supported options. By default the value is stored as std::string.
However, there are options that can modify this default behaviour. The protocol definition classes are expected to provide a way to pass extra application specific options to the string field definition. It is explained in more details in Application Specific Customisation of Fields section below.
Also similar to Array List Fields, fixed length strings are defined using comms::option::def::SequenceFixedSize option, and just like with lists it does NOT automatically resize inner string, just ensures right amount of characters "on the wire" when field is serialised.
Floating point value fields are defined using comms::field::FloatValue They are very similar to Integral Value Fields, but use float or double as its internal storage type. They abstract the IEEE 754 floating point values, which are serialised "as is" with either big or little endian encoding. The floating point value fields also support the same scaling and units conversion just like Integral Value Fields.
Some protocols may define optional fields, which may exist or be missing based on information recorded in other fields. For example there is a "flags" bitmask field which specifies whether the following field exists or missing. The optional field may also be tentative, i.e. if there is enough data in the input buffer it exists, and missing otherwise. The COMMS library provides comms::field::Optional which is a mere wrapper around other fields and provides an ability to set the optional state of the field.
The default mode of such field is "tentative", which means read if there is data available in the input buffer, and write if there is enough space in the output buffer.
The default mode can be changed using comms::option::def::ExistsByDefault or comms::option::def::MissingByDefault options. For example
NOTE, that inner ValueType of such field is wrapped actual field and both comms::field::Optional::value() and comms::field::Optional::field() member functions allow to access it. For example:
Some protocols may require usage of heterogeneous fields or lists of heterogeneous fields, i.e. the ones that can be of multiple types. Good example would be a list of properties, where every property is a key/value pair or a type/length/value triplet. The key (or type) is usually a numeric ID of the property, while value can be any field of any length. Such fields are defined using comms::field::Variant class. It is very similar to Bundle Fields, but serves as one big union of provided member fields, i.e. only one can be used at a time.
As an example for the key/value pairs let's assume usage of three value types:
The COMMS library provides comms::field::Variant field to allow such heterogeneous fields and the protocol definition may look something like this:
IMPORTANT: The comms::field::Variant field uses uninitialized implementation dependent storage area able to be initialized and hold any of the provided member field types (but only one at a time). The inner ValueType type contains definition of the used storage type and value() member function which returns reference to the used storage area. These type and function should NOT be used directly. Instead, the protocol definition is expected to use COMMS_VARIANT_MEMBERS_NAMES() macro, which generates appropriate access and conversion wrapper functions around comms::field::Variant::initField() and comms::field::Variant::accessField().
The usage of COMMS_VARIANT_MEMBERS_NAMES() in the above is equivalent to having the following member types and functions defined
NOTE, that the provided names have propagated into definition of FieldIdx enum, all initField_* and accessField_* functions, as well as inner Field_* types.
When variant field object is instantiated, accessing the currently held field can be tricky though. There is a need to differentiate between compile-time and run-time knowledge of the contents.
When preparing a variant field (or message with variant fields) to be sent out, usually the inner field type and its value are known at compile time. The initialization of the field can be performed using one of the initField_*() member function described above:
or use comms::field::Variant::initField() member function and generated FieldIdx enum as compile time access index:
It is possible to re-initialize the field as something else, the previous definition will be properly destructed.
If the variant field has been initialized before, but there is a need to access the real type (also known at compile time), use appropriate accessField_*() member function:
or use comms::field::Variant::accessField() member function and generated FieldIdx enum as compile time access index:
There are cases (such as handling field after "read" operation), when actual type of the Variant field is known at run-time. The most straightforward way is to inquire the actual type index using comms::field::Variant::currentField() function and then use a switch
statement and handle every case accordingly.
However, such approach may require a significant amount of boilerplate code with manual (error-prone) "casting" to appropriate field type. The COMMS library provides a built-in way to perform relatively efficient (O(log(n)) way of dispatching the actual field to its appropriate handling function by using comms::field::Variant::currentFieldExec() member function. It expects to receive a handling object which can handle all of the available inner types:
NOTE, that every operator() function receives a compile time index of the handed field within a containing tuple. If it's not needed when handling the member field, just ignore it or static_assert on its value if the index's value is known.
The class of the handling object may also receive the handled member type as a template parameter
The example above covers basic key/value pairs type of properties. Quite often protocols use TLV (type/length/value) triplets instead. Adding length information allows having multiple value fields to follow (some of them may be introduced in future versions of protocols) as well as receiving unknown (to earlier versions of the protocol) properties and skipping over them. The definition above may be slightly altered (see below) to support such properties:
The rest of the handling code presented above applies for this kind as well with one small nuance. The value of the length field depends on the value of val (especially with variable length fields like strings).
When such property field is default constructed, the length is updated to a correct value.
In case the val field of the prop3 gets updated, the value of length field is not valid any more. There is a need to bring it into a consistent state by calling refresh() member function.
Note, that there is no need to call refresh() after every update of a variant field. Usually such updates are done as preparation of the message to be sent. It is sufficient to call doRefresh() member function of the message object at the end of the update.
The COMMS library provides multiple options to modify default behaviour of field classes. Many of them modify the way how the field is (de)serialised. Such options are expected to be used in actual protocol definition code. However, there are also options that modify used data structures or field's behaviour. Such options are application specific, and the protocol definition is expected to provide a way on delivering such options to fields definitions. The recommended way for the protocol definition is to have separate struct called DefaultOptions which defines all the relevant extension options as its inner types. For example, The message definition may look like this:
The inner structure of DefaultOptions struct is not important, but the recommended practice is to resemble the scope of fields. It may look like this
The application is expected to provide its own options describing struct / class if needed. The easiest way is to extend provided DefaultOptions and override selected types. For example
The definition of the message(s) may look like this:
If there is a need to pass more than one extra option to a field, these options can be bundled together is std::tuple.
As was already mentioned earlier the default storage type (ValueType) of Array List Fields is std::vector, while default storage type for String Fields is std::string. These types are suitable for most of C++ applications, but may be unsuitable for bare-metal embedded platforms. That why the protocol definition must allow additional customisation of such fields.
The COMMS library provides multiple options to change the default storage type. There is comms::option::app::FixedSizeStorage option. When passed to the comms::field::ArrayList or comms::field::String, it changes the default storage type to be comms::util::StaticVector or comms::util::StaticString respectively. These types expose the same public API as std::vector or std::string, but use pre-allocated storage area (as their private member) to store the elements / characters. Note, that the comms::option::app::FixedSizeStorage option has a template parameter, which specify number of elements (not necessarily bytes) to be stored. If the field definition already uses comms::option::def::SequenceFixedSize option to specify that number of elements is fixed, there is comms::option::app::SequenceFixedSizeUseFixedSizeStorage option which has the same effect of forcing comms::util::StaticVector or comms::util::StaticString to be storage types, but does not require repeating specification of storage area size. For example, if message type is defined to use provided DefaultOptions, then the storage type of field3 will be std::string
However, if message type is defined to used described earlier MyOptions, then the storage type of field3 will be comms::util::StaticString
NOTE, that using default std::vector / std::string or provided comms::util::StaticVector / comms::util::StaticString will involve copying of the data to these storage areas during the read operation. If the input buffer is contiguous, i.e the pointer to the last element is always greater than pointer to the first one (not some kind of circular buffer), then copying of the data may be avoided by using comms::option::app::OrigDataView option. The option will change the default storage types to be std::span<std::uint8_t> or std::string_view respectively. In case standard variants are unavalable due to used C++ standard or insufficient standard library version then comms::util::ArrayView and/or comms::util::StringView will be used instead.
NOTE, that passing comms::option::app::OrigDataView option to comms::field::ArrayList is possible only if it is list of raw data (std::uint8_t is used as element type).
If default standard std::vector / std::string or all the provided by the COMMS library storage type are not good enough, it is possible to specify custom storage type using comms::option::app::CustomStorageType option. For example:
While allowing extra customisation for fields like list or string is a "must have" feature of the protocol definition, it may also allow additional customisation of other fields as well. For example, let's assume that field1 of previously mentioned Message1 is a numeric field, that defined the following way:
If not other options are passed as message::Message1Fields::field1 inner type of application specific options struct / class, then the inner value of field1 will be initialised to 10 upon construction and the range of valid values will be [10, 20]. Such default settings may be modified using various options. For example, making the value to be initialised to 5 and adding it to valid values may look like this:
After these extra options are passed to the field's definition it becomes equivalent to
NOTE that COMMS library processes all the options bottom-up, i.e. starts with comms::option::def::ValidNumValueRange<10, 20> (which records initial valid range [10, 20]), then processes comms::option::def::DefaultNumValue<10> (which records default value to be 10), then processes comms::option::def::ValidNumValue<5> (which adds value 5 to the existing valid ranges), then processes comms::option::def::DefaultNumValue<5> (which changes the default value to be 5).
In case the application needs to override originally defined valid range(s) of the field, it can use comms::option::def::ValidRangesClear option, which will clear all previously defined valid ranges and will start accumulating them anew. For example:
Additional option that may be quite useful is comms::option::def::FailOnInvalid. It causes the read operation to fail with provided error status when read value is not valid (valid() member function returns false).
There may be other useful options, which are not covered by this tutorial, please open reference page of a required field class for the list of supported options and read their documentation for more details.
As was mentioned earlier, every field class has value() member function, which provides an access to internal value storage. Quite often there may be case when explicit cast is required.
The code above is boilerplate one, which in general should be avoided, because in case of the field definition being changed, the cast below must also be changed. Such boilerplate code can be avoided by using extra compile time type manipulations:
However, it's too much typing to do and quite inconvenient for the developer. The COMMS library provides comms::cast_assign() stand alone function which can be used for easy assignments with implicit static_cast to appropriate type. It should be used for any arithmetic and/or enum storage values.
There may also be cases when value of one field needs to be assigned to value of another type. If static_cast between the values works, then it is possible to use comms::cast_assign() function described in Field Value Assignment section above.
However, there may be cases when such cast is not possible. For example value of 1 byte comms::field::IntValue needs to be assigned to a comms::field::Bitfield length of which is also 1 byte, but it splits the value into a couple of inner members. In this case the comms::field_cast() function should be used.
NOTE, that casting and assignment is performed on the field objects themselves, not their stored values.
In addition to definition of the messages and their contents, every communication protocol must ensure that the message is successfully delivered over the I/O link to the other side. The serialised message payload must be wrapped in some kind of transport information prior to being sent and unwrapped on the other side when received. The COMMS protocol defines multiple so called layers (defined in comms::protocol namespace). The transport framing will be defined using those layer classes and may be called ProtocolStack or Frame. Its definition is expected to look something like this:
The first template parameter (TMessage) is common message interface class described earlier in Defining Message Interface Class section.
The second template parameter (TInputMessages) is std::tuple of input messages that read operation is expected to recognise. The message classes must be sorted by the ascending order of message IDs. The protocol definition is also expected to define AllMessages tuple which lists all the protocol messages, which can be used as reference.
The third template parameter (TAllocationOptions) is extra option(s) (bundled in std::tuple if more than one) to be passed to comms::protocol::MsgIdLayer class which is responsible for message allocation. By default the message object is dynamically allocated, it is possible to modify such behaviour by using comms::option::app::InPlaceAllocation option. It will be explained in more details further below.
The fourth template parameter (TPayloadOptions) is irrelevant in most cases. It has a meaning only when transport wrapping values are cached for further analysis. It will also be explained in more details further below.
NOTE, that ProtocolStack definition is actually an alias to one of the classes from comms::protocol namespace. To get a detailed information on available public API please reference to one of them, for example comms::protocol::SyncPrefixLayer.
It may also happen, that the extra options (TAllocationOptions and TPayloadOptions) are defined inside the recommended DefaultOptions structure and be passed to the layers definitions themselves. Such approach is undertaken by the commsdsl2comms code generator application from the commsdsl project.
The COMMS library provides some convenience input processing and message dispatching functionality suitable for most cases. There is comms::processAllWithDispatch() function which is responsible to process provided data in input buffer, detect and allocate message objects, and dispatch them to provided handler. For example:
The code above will dispatch allocated message objects to the provided handler using comms::dispatchMsg() function, which in turn performs compile time analysis of supported message types and can result in either using polymorphic (see comms::dispatchMsgPolymorphic()) or static binary search (see comms::dispatchMsgStaticBinSearch()) way of dispatching. If such default behavior of the COMMS library is not good enough for a particular application, it is recommended to use comms::processAllWithDispatchViaDispatcher() function instead, which uses comms::MsgDispatcher to force a particular way of dispatching.
If the described above processing functions (comms::processAllWithDispatch() and comms::processAllWithDispatchViaDispatcher()) are not good enough for a particular application, there are several auxiliary functions that can be used to implement application specific input data processing loop. Please refer to the documentation of:
There are protocols when number of input messages is very limited (one or two) such as various types of acknowledgment. To support such case read() member function of the ProtocolStack as well as processing function(s) described above support reception of reference to the real message object (instead of MsgPtr pointer).
NOTE, that in such case there is no need for either Polymorphic Read of Payload (Deserialisation) or Polymorphic Dispatch Message for Handling. In fact, no virtual function call is used in the code above.
By default, the message object is dynamically allocated. However, some applications (especially bare-metal ones) may require something different. The COMMS library has comms::option::app::InPlaceAllocation option, which may be passed as third template parameter (TAllocationOptions) to ProtocolStack type definition. It statically allocates (in private data members) storage area, that is capable to store any message object from the std::tuple passed as second parameter to ProtocolStack type definition. The message allocation is performed using "placement new" operator, which means only one message object can be allocated at a time. The smart pointer holding the message (MsgPtr) is still std::unique_ptr, but with custom deleter.
In case the Message Interface class does not define any virtual function (which results in lack of virtual destructor), the COMMS library also creates a custom deleter for the defined smart pointer to the allocated message (see MsgPtr) to allow proper downcast of the held pointer to the appropriate class and invoke the correct destructor.
In such cases (that require usage of custom deleters) please refrain from releasing the held object and/or changing the smart pointer type.
In general, if message ID cannot be recognised, then appropriate message object cannot be created and processed, i.e. the reading loop will just discard such input. However, there are applications that serve as some kind of a bridge or a firewall, i.e. need to recognise and process only limited number of input messages, all the rest need to be forwarded "as-is" or maybe wrapped in different transport frame before sending it over different I/O link. To help with such task COMMS library provides comms::GenericMessage class. There is also comms::option::app::SupportGenericMessage option that can be passed as third template parameter (TAllocationOptions) to the ProtocolStack type definition. It will force of creation comms::GenericMessage object when appropriate message object is not found.
NOTE, that comms::GenericMessage has only single field, which is a list of raw data (comms::field::ArrayList). The read operation of such field will result in copying the data from input buffer to internal storage of this field. The comms::GenericMessage class has also other template parameters (except common message interface class). The second template parameter is option(s) that are going to be passed to this comms::field::ArrayList field. If allocated message is not going to outlive input buffer, than it may make sense to pass comms::option::app::OrigDataView as second template parameter to comms::GenericMessage.
Also note, that it is possible to combine usage of comms::option::app::InPlaceAllocation and comms::option::app::SupportGenericMessage as the third template parameter to ProtocolStack type definition using std::tuple bundling.
The easiest way to implement write functionality is to use the ability of the message object to perform polymorphic write.
In order to support such polymorphic write the common message interface class MyMessage has to have the following polymorphic functionality
In case the transport framing reports size that follows, the support for Polymorphic Serialisation Length Retrieval is also recommended. It will be used to write the required value right away. However, if not provided the dummy value (0) will be written at first, then after the write operation is complete, the number of written bytes will be calculated and the previously written dummy value updated accordingly. NOTE, that such update is possible only if iterator used for writing is random-access one. Otherwise, such update won't be possible. In this case comms::ErrorStatus::UpdateRequired error status will be returned. It means that the write operation is incomplete, there is a need to perform update() call with random-access iterator. For example, let's assume the Polymorphic Serialisation Length Retrieval is not supported and std::back_insert_iterator<std::vector<std::uint8> > is passed to MyMessage with comms::option::app::WriteIterator option. the message object to perform polymorphic write
Similar scenario of a need to handle comms::ErrorStatus::UpdateRequired error status may occur when transport framing contains checksum value and output (not random-access) iterator is used. The checksum calculation requires going over the written data to calculate the value. However, it won't be possible to do right away, the update() call must follow.
There may be cases when update() operation also requires knowledge about message object being written. For example when remaining size information shares the same bytes with some extra flags, which can be retrieved from the message object being written. To support such cases there is also overloaded update() member function which also receives reference to message object/
The ProtocolStack does not require usage of polymorphic write for message serialisation all the time. If number of messages being sent is not very high, sometimes it makes sense to avoid adding an ability to support polymorphic write in the common interface. In this case the sending functionality can be implemented using a template function where actual message objects are passed as the parameter:
Such implementation does not require any polymorphic behaviour from the message object being sent, it takes all the required information from the direct calls to non-virtual doGetId() (see comms::MessageBase::doGetId()) and doLength() (see comms::MessageBase::doLength()). The payload write is also performed using direct call to doWrite() (see comms::MessageBase::doWrite()).
All the examples above do not store the read/written transport fields anywhere. In most cases it is not needed. However, if need arises they can be cached during the read/write operations and accessed later. The ProtocolStack also defines AllFields type which is std::tuple of all the fields used by all the layer classes.
Also, the ProtocolStack defines readFieldsCached() and writeFieldsCached() member functions which are substitutes to normal read() and write(). The first parameter to these functions is reference to the AllFields bundle object.
The layer class that is responsible to read/write payload data (see comms::protocol::MsgDataLayer) uses comms::field::ArrayList to define a field that will store the payload when "caching" operations are performed. That's where the fourth template parameter (TPayloadOptions) to ProtocolStack definition comes in play. In case the the input / output buffer outlives the AllFields object, consider passing comms::option::app::OrigDataView option as the fourth template parameter to ProtocolStack definition, which will pass it to to the field containing the message payload raw data. Otherwise, the payload part from the read / written buffer will also be copied to storage area of the cached payload field.
As was mentioned earlier, the protocol stack is defined using so called layer classes (defined in comms::protocol namespace) by wrapping one another. Access to the appropriate layer may be obtained using a sequence of calls to nextLayer() member functions (see comms::protocol::ProtocolLayerBase::nextLayer()). Alternatively, the protocol stack definition is also expected to use COMMS_PROTOCOL_LAYERS_ACCESS() macro to generate a convenience access functions. For example
The provided names are used in generation of access member functions with layer_ prefix. The code above is equivalent to having the following member functions defined.
Then the access to the appropriate layer as as simple as calling appropriate layer_*() member function. Once the access is obtained, it is possible to call accessCachedField() (see comms::protocol::ProtocolLayerBase::accessCachedField()) member function to get an access to appropriate field. For example:
Version v2.0 the COMMS library has introduced additional way to retrieve values from stripped message framing. There are several functions listed below that can be used to pass extra variadic arguments to the "read" functions of the protocol stack (see read() and readFieldsCached()).
For example there can be a case when Defining Message Interface Class does not support Polymorphic Dispatch Message for Handling. In this case it might be required to retrieve the information about message ID and its relevant index in order to dispatch message object to its appropriate handling function (see Advanced Guide to Message Dispatching).
When a message is received over I/O link and successfully deserialised, it needs to be dispatched to appropriate handling function. Many developers write quite big (depends on number of messages it needs to handle) switch statement that checks the ID of the message and calls appropriate function in the relevant case area. Other developers try to minimise amount of boilerplate code by introducing hand written map from message ID to the handling function. Such approaches may be quite inefficient in terms of execution performance as well as development effort required to introduce a new message when protocol evolves.
The COMMS library has a built-in efficient (O(1)) dispatch mechanism, which uses "Double Dispatch" idiom. The Polymorphic Dispatch Message for Handling section above described using comms::option::app::Handler option, which adds polymorphic dispatch() member function to the common interface class (MyMessage). The provided handling class (MyHandler) is expected to define handle() member function for every message class it is expected to handle.
and a single handle() member function that receives common message interface class for all the messages that need to be ignored or handled in some other common way. If this function is not added, the compilation may fail.
For example, let's assume that MyHandler defines message to properly handle Message1, but doesn't care about Message2. In this case
NOTE, that MyMessage class is only forward declared when Defining Message Interface Class (MyMessage), but needs to be properly defined (included if in separate file) when defining Transport Framing.
There may be a need for being able to dispatch message for multiple independent handlers. Good example would be having a state-machine, where every state processes the same message in different way. It is simple to implement by just defining the handling functions as virtual.
Defining the concrete handlers:
Now, any of the handlers may be used to handle the message:
The COMMS library provides some help in defining custom message handlers. There is comms::GenericHandler class that receives at least two template parameters. The first one is a common interface class for all the handled messages (MyMessage). The second template parameter is all the types of all the custom messages the handler is supposed to handle, bundled into std::tuple. Remember defining a bundle of messages (MyInputMessages) that need to be recognised during Reading Transport Framing and Message Payload? It's probably a good place to reuse it or define a new tuple if there is a mismatch.
As the result the comms::GenericHandler implements virtual handle() function for all the provided messages including the provided interface one. The code that automatically generated by comms::GenericMessage is equivalent to the one below.
Now, what remains is to inherit from comms::GenericHandler and override the functions that need to be overridden:
Pay attention that comms::GenericHandler doesn't declare its destructor as virtual. If the handler object requires support for polymorphic delete (destruction), make sure to declare its destructor as virtual.
All the examples above used void as a return type from handling functions. It is possible, however, to return value of handling result. In order to achieve this the handler class needs to define inner RetType type and all the handle() functions must return it. For example:
If inner RetType type is defined, it is propagated to be also the return type of the comms::Message::dispatch() member function as well. As the result the developer may use constructs like this:
If comms::GenericHandler class is used to define the handler class, its third template parameter (which defaults to void), can be used to specify the return type of handling functions.
NOTE, since version v1.1 the COMMS library supports other ways to dispatch a message object to its handling function in addition to one described above. Please read Advanced Guide to Message Dispatching page for more details.
Some protocols may use extra values in their transport information, which may influence the way how message payload is being read and/or message object being handled. Good example would be having a protocol version, which defines what message payload fields were serialised and which were not (because they were introduced in later version of the protocol). Such extra information is stored in the message object itself. If this is the case, the protocol definition is expected to use comms::option::def::ExtraTransportFields option in addition to specifying serialisation endian and message ID type (described in Defining Message Interface Class). (see Fields Definition Tutorial) and bundled in std::tuple:
Usage of comms::option::def::ExtraTransportFields option as well as COMMS_MSG_TRANSPORT_FIELDS_NAMES() macro in the message class definition is equivalent to having the following types and member functions defined
For reference see also description of comms::Message::transportFields() member function.
Access to the version information given a reference to message object may now be implemented as:
NOTE, that the defined "extra transport fields" are there to attach some extra information, delivered as part of transport framing, to message object itself. These fields are NOT getting serialised / deserialised when message object (payload) being read / written.
The comms::Message interface class defines comms::Message::hasTransportFields() static constexpr member function, which may be used at compile time to determine whether the comms::option::def::ExtraTransportFields option has been used, i.e. the message interface class defines mentioned earlier types and functions.
The COMMS library contain a built-in protocol version support when version info is provided within Extra Transport Values. To support this feature, the message interface definition class needs to use comms::option::def::VersionInExtraTransportFields option, in addition to comms::option::def::ExtraTransportFields option itself, to specify which field is version.
Usage of comms::option::def::VersionInExtraTransportFields option generates inner VersionType type (see comms::Message::VersionType) as well as version() access functions (see comms::Message::version()) for direct access to it. It is equivalent to having the following functions defined:
NOTE, that updating the version information only modifies the value of the relevant transport fields itself. The message contents are not being updated. There is a need to invoke doFieldsVersionUpdate() member function (see comms::MessageBase::doFieldsVersionUpdate()), which will do the job of updating message contents accordingly.
The refresh functionality (direct or polymorphic) also contains update of the fields' version, which can be used instead.
Some communication protocols have values (such as protocol version information), which are reported in the payload of one of the messages and may influence the way how other messages being deserialised and/or handled. Usually it is some kind of CONNECT message. Such scenario is implemented in the very similar way to Extra Transport Values. The protocol stack is still defined using comms::protocol::TransportValueLayer but with comms::option::def::PseudoValue option. Such layer contains the "pseudo" field in its internal data members and pretends to read it during read operation.
Let's assume the protocol framing is defined to be
but there is some kind of CONNECT message that reports client protocol version. The transport framing will probably be defined as if it handles the following stack.
The transport framing is expected to be defined as below providing an access to the VERSION handling layer:
Then after reception and handling of the mentioned CONNECT message, the stored pseudo version field may be accessed using pseudoField() member function of comms::protocol::TransportValueLayer layer (see comms::protocol::TransportValueLayer::pseudoField()) and updated with reported value.
From now on every received message will have appropriate extra transport field updated accordingly right after receiving of the message and before its read operation being performed.
When most of the messages are uni-directional, i.e. are either only sent or only received, then it may make sense to split the common message interface class MyMessage (see Defining Message Interface Class) into two interface classes: for input and for output. It will save the generation of unnecessary virtual functions which are not used, but occupy space.
There may be also an opposite case, when most of the messages are bi-directional, while only few go one way. For most applications the single common message interface will do the job. However generating unnecessary virtual functions for bare-metal application may be a heavy price to pay, especially when ROM size is small. The COMMS library provides several options that inhibit generation of virtual functions. These extra options need to be passed to comms::MessageBase class when defining a message class. Available options are:
In order to be able to pass these extra options to message definition classes, the support from the latter is required. If the protocol definition follows a recommended approach the message definition is expected to reuse extra protocol options structure (DefaultOptions) described earlier.
As an example let's assume, that Message1 is only sent by the application, but never received, and Message 2 is the opposite, only received by never sent. In this case defining the following options structure and passing them in to message definition classes will work
Sometimes the public interface of the messages, generated by the COMMS library out of available options passed to comms::Message, may be insufficient for some applications and its interface needs to be extended with custom member functions. It is easy to achieve by just implementing required function in common message interface class
All the protocol messages must also be extended in order to implement missing virtual function
Don't forget to bundle newly defined messages in std::tuple and pass them to Transport Framing definition or Generic Handler when needed.