Contents

Introduction

libaes70 is a C++11 implementation of the AES70 standard. libaes70 is a header-only library which can be used to build aes70 devices.

Versioning

libaes70 loosely follows the semantic versioning scheme. Incompatible changes trigger a major version change. Additional features or compatible changes happen within minor versions. Releases which only contain bugfixes or minor compatible changes happen in patch releases.

Major versions each have their own git branch. Minor and patch versions are reflected in git as tags. The master branch is the development branch which will be branched off into the next major release at some point.

How to use this documentation

This README file tries to give an introduction into how to design AES70 devices using this library. This README is best viewed as part of the generated HTML documentation. You can either generate this documentation export from the repository itself or find an online version at docs.deuso.de/libaes70.cpp. This documentation can be searched interactively (hit Tab to open the search dialog). This search feature is an easy way to jump between API documentation quickly.

Getting started

This library is header-only which means that in order to use it within a project, the include folder needs to be added to the include path of the compiler, e.g. with the -I option in gcc.

The folder examples contains some simple example devices which can be a good starting point when developing new applications using this library.

A good starting point for understanding AES70 and for designing an AES70 object tree for a device is to look at the available AES70 classes and their interfaces. These interface specifications can either be found within the standards documents or as a more accessible export into a HTML documentation at docs.deuso.de/AES70.

For each control classes defined in AES70 (e.g. OcaGain) there exists a corresponding class template inside of this library. Similarly, for each data type defined inside AES70 there exists a data type in this library.

Recommended Development strategy

AES70 is an interface specification. In some cases, it may also serve as a useful starting point for designing the object model inside of the device application. However, this only works well in simple situations, more complex cases will likely benefit from a more clear seperation:

  1. application code with a well-defined API for controlling parameters
  2. classes which implement specific AES70 class interfaces to access 1.

The classes implementing the AES70 class interfaces would then communicate with the application code to set parameters and handle change events. These interface classes would then be 'wrapped' using the AES70 class templates of this library in order to make them available via AES70. Using these wrapped classes the AES70 object tree can be built.

The concept of how the interface classes can be written is explained in the next section.

Basic concepts - design by introspection

The AES70 Standard consists of an interface specification for to represent functionality of audio devices and a protocol specification of how to communicate with those interfaces over a network.

The interface specification in AES70 describes a tree of classes, their methods, properties and events. libaes70 implements this interface as a series of introspection class templates. These classes detect which AES70 methods and events have been implemented in a given class and provoide the necessary glue to expose those features via AES70.

    class MyGain
    {
      float gain;
    public:
      static const char* Role = "My Gain";

      aes70::OcaStatus SetGain(float gain)
      {
        if (!(gain >= 96.0 && gain <= 6.0))
          return OcaStatus::ParameterOutOfRange;

        this->gain = gain;

        return OcaStatus::OK;
      }

      std::array<float, 3> GetGain()
      {
        return { gain, -96.0, 6.0 };
      }
    };

    aes70::device::OcaGain<MyGain> my_aes70_gain1;
    aes70::device::OcaGain<MyGain> my_aes70_gain2;

In the above example the class aes70::device::OcaGain detects the presence of the property Role and the two methods SetGain and GetGain and expose them using AES70. The resulting class aes70::device::OcaGain<MyGain> then supports that part of AES70 which is required to expose all features that MyGain implements. All this happens at compile time which means that no code is generated for AES70 features which are not used.

Some rules of thumb for how to implement method from the AES70 specification inside of the interface classes:

Arguments:

  • Add each argument as specified in the AES70 specification.
  • Give each argument the type as specified in AES70. Here this either means using the 'natural' corresponding type (e.g. std::string for OcaString) or a supported equivalent. See Data Types for more information.
  • If an argument is not required, specify it as aes70::OCP1::ignore which will make sure that this specific argument is not decoded.

Return values:

  • If a method has more than one return value, return them as std::tuple (or std::array if they all have the same type).
  • If the method has one return value, return that value.
  • If a method has no return value, return void if it always succeeds and OcaStatus otherwise.
  • In more complex cases, consult the documentation of aes70::device::Method.

Each method defined in AES70 is supported by libaes70 with various different signatures. They attempt to cover different use-cases. A list of all methods defined by each wrapper class and the corresponding list of different signatures can be found in the API documentation (see e.g. aes70::device::OcaGain and aes70::device::Method).

Defining the object tree

AES70 devices contain a tree of objects. This tree starts at the root block, which can contain other blocks or ordinary objects. This library supports two different ways of building this device tree:

  1. static devices: the device tree is fixed at compile time. This is useful for simple devices and can be used to create AES70 device which do not require any dynamic memory allocation.
  2. dynamic devices: the device tree is created at run-time as a data structure. This is useful for larger, more complex devices which represent complex devices which are dynamic or configurable.

The following two sections describe how to build static devices and dynamic devices. The interface classes described earlier are the same for both static and dynamic devices, which makes it straightforward to e.g. refactor a static device into a dynamic one.

Static devices

Static devices are devices which have a fixed parameter tree. In principle AES70 also allows dynamic devices in which objects appear and disappear during run-time. libaes70 has special APIs for build static devices, in which the full object tree can be created in static storage. This has the benefit of having very deterministic memory requirements which is an important feature for embedded applications.

In AES70 the parameter or object tree consists of blocks. Blocks are special objects which may contain children and offer APIs for clients to discover the objects they contain. In libaes70 this object tree can be constructed using the function make_block.

    using aes70::static_device::make_block;
    using aes70::static_device::make_root;

    auto channel1 = make_block("Channel1", my_aes70_gain1);
    auto channel2 = make_block("Channel2", my_aes70_gain2);

    auto root = make_root(channel1, channel2);

The function make_root creates a special block object which is the so-called root block. This root block exists in every AES70 device and represents the unique root object of the object tree. Blocks can be arbitrarily nested which allows building complex devices.

    auto root = make_root(
      make_block("foo",
        channel1,
        channel2
      )
    );

Note that make_block and make_root support both lvalue and rvalue references as arguments. When they are passed an rvalue reference, the argument object is moved and stored in the object tree itself. If the argument is a lvalue reference, only the reference is stored. The latter is useful in situations where we need easy access to an object in the tree, e.g. for updating metering data or similar.

We can then create a device object using the above root block with call to aes70::static_device::make_device.

    auto device = aes70::static_device::make_device(root);

The above code looks 'dynamic', however it can be used during static initialization. All objects involved would then end up in static storage and be initialized before main is called.

Static device examplesl can be found under examples/devices.

Dynamic devices

The construction of dynamic devices follows a very natural pattern. The first step is to create the device object.

    aes70::dynamic_device::device dev;

Then we can start adding objects or blocks to the device root block by calling aes70::dynamic_device::device::create_block or aes70::dynamic_device::device::create_child. The return value of these methods is a std::shared_ptr of the given data type.

    auto channels = dev.create_block("Channels");

    for (int i = 0; i < 10; i++)
    {
      auto channel = dev.create_block(std::to_string(i));

      channel->create_child<aes70::device::OcaGain<MyGain>>();
      channel->create_child<aes70::device::OcaUint8Sensor<MyLevel>>();
    }

A static device example can be found under examples/dynamic_devices.

Property Change Events

When implementing a Setter such as SetGain in the example above, the corresponding property change event will be automatically generated by libaes70 (details about this are outlined in the API documentation of aes70::device::Setter. In situations where the property change is not generated by the by a AES70 method call, the property change event can be generated using the template aes70::property_change_event. This template class can be used to define property change events which are then going to be picked up by the introspection classes.

For example, using aes70::device::OcaUint8Sensor to implement a level meter could be done like this:

    struct MyLevel
    {
      aes70::property_change_event<uint8_t> OnReadingChanged;
      uint8_t reading;

      void update_level(uint8_t level)
      {
        reading = level;

        // trigger the property change event
        OnReadingChanged(level);
      }

      std::array<uint8_t, 3> GetReading()
      {
        return { reading, 0, 255 };
      }
    };

In the above example by calling update_level the meter reading would be updated and a property change event would be generated and sent to all subscribed clients.

See examples/devices/gains_and_levels for an example using property change events to implement level meters.

Networking

libaes70 supports different networking libraries, currently supported are libuv and lwIP. The API of the integration is very similar in all cases. For libuv setting up a device would look like this:

    using Device = decltype(device);

    int
    main(int argc, char **argv)
    {
      // initialize the device structure
      device.init();

      libuv::tcp::port<aes70::port<Device>>
        tcp(50001, "0.0.0.0", uv_default_loop(), device);
      return uv_run(uv_default_loop(), UV_RUN_DEFAULT);
    }

This code is enough to set up a AES70 TCP device on port 50001. See examples/devices/ for examples with more complex configurations, such as UDP and WebSocket support.

Data Types

AES70 defines a series of data types for use in the protocol. The protocol specifications describes how these data types are encoded for transport over the network. This encoding specification is called OCP.1 inside of the AES70 standard.

libaes70 implements the encoders for OCP.1 in the classes inside the namespace aes70::OCP1. The encoder implementations use template functions to allow them to be used with a wide range of data types. Consequently, most APIs of libaes70 are data type agnostic and can be used both with standard data types from the STL as well as custom implementations.

For example, the AES70 data type OcaList<OcaUint32> could be represented both as std::vector<uint32_t> or std::forward_list<uint32_t>.

Despite the flexibility of choosing the data type used, libaes70 also contains implementation for all data types which are part of the AES70 standard. The implementation chosen depends on the category the type belongs to.

Enumerations

Enumerations are implemented as enum classes.

Basic types

Basic types can be throught of all those types for which some 'natural' representation exists. These are things like integer types, strings, lists, maps, etc. All basic types are implemented as typedefs to types from the standard library. Their definitions can be found in types_basic_stl.h.

It is possible to use libaes70 with definitions for the basic data types other than those from the standard library. This can be done by defining all types inside of types_basic_stl.h manually and defining the preprocessor symbol AES70_BASIC_TYPES_DEFINED before including the libaes70 headers.

Struct types

Struct types are all data types which are structs whose members are either enumerations, basic types or struct types. Note: Struct types in the AES70 standard which only contain a single member are implemented as typedefs in libaes70.

Struct types are implemented in libaes70 as structs. Their implementations can be found in the source file types_complex.h.

Advanced topics

Asynchronous responses

In simple situations methods called through AES70 immediately complete their operation and a response can immediately be sent back to the client. However, in some cases operations may complete asynchronously. This is supported in libaes70 using aes70::async_response_generator. An example of how to use it can be found in examples/devices/async.

Custom Classes

The AES70 standard allows the definition of custom classes. This library contains a tool which can be used to generate code for custom classes from a JSON specification. This tool can be found in tools/generator. This tool is written in NodeJS and can be run from the command line to generate code for a given JSON specification either for this libary, for libaes70.NET and AES70.js.

This repository contains some tests defining and using custom classes under tests/integration/custom_classes. The example JSON files in that directory can be used as starting points for defining a public class. Note that the generator tool is currently limited, e.g. it does not support all possible standard data types and neither does it allow for defining custom events.

Note that when defining a custom class one needs to choose a class ID. Generally, the class ID must be derived from one of the existing standard classes. For instance, since OcaSensor has class ID 1.1.2, a subclass of OcaSensor needs to have class ID 1.1.2.XXX. The allocation of class IDs for custom classes is specified in the AES70 standard documents, specifically in section 4.2.2.6.3 Nonstandard class IDs of AES70-1-2018. In short, prorietary (or nonstandard class IDs) should be prefixed by the OUI of the manufacturer when used in production environments. This guarantees that class IDs of different manufacturers do not collide.

Caveats

Copy and Move constructors

The API for building the object tree above will copy or move initialize the objects involved. This means that the objects need to be move or copy constructible. In simple situations this will automatically be the case due to the presence of the implicit move and copy constructors. In more complex situations a move constructor must be implemented.

A common symptom of a missing move constructor is when certain property change events do not seem to work correctly. This is usually due callbacks being triggered in the old object before it was moved.

Alternatively, the methods

      void init(aes70::device::device* Device, uint32 ObjectNumber, uint16_t BlockOffset)
      void init(aes70::device::device* Device, uint32 ObjectNumber)

may be implemented on an object, which will be called when aes70::device::device::init is called. The argument ObjectNumber is the AES70 object number of the object and BlockOffset is the offset within the parent block. The BlockOffset is always 0 in dynamic devices.

AES70 compliancy

For an object to be AES70 compliant it has to implement a minimal set of methods as described in the OCC MIN section in the AES70 standard. For users of this library this usually boils down to objects having a role name and supporting object locking. When strict AES70 compliancy is required, consider inheriting from aes70::device::lockable_object_base which implements object locking as required by the AES70 standard.

Platform Support

The core of libaes70 is platform agnostic, which means that it is designed in such a way to make integration into different networking and IO frameworks straightforward. libaes70 currently contains platform support for lwIP for embedded applications and libuv for development on hardware with standard operating systems (linux, MacOS or Windows). We plan to add more platform integration support as the need arises.

Compiler Support

libaes70 has been tested to work with the following compilers

  • GCC versions 6.4.0, 7.3.0
  • CLANG 6.0.1
  • Microsoft Visual Studio 2017, 2019

This library does not use or require RTTI.

Exceptions

The library code itself will only generate no exceptions when compiled with AES70_NO_EXCEPTIONS defined. However, the dynamic device part of this library rely on standard containers such as std::vector and std::unordered_map which may throw exceptions, e.g. in out-of-memory situations.

Compilation options

The code of this library uses assert in many places. These run-time checks are active unless the define NDEBUG is set during compilation. It is therefore recommended to compile production builds with NDEBUG.

For development and debugging it is recommended to compile without NDEBUG defined and to additionally define AES70_DEBUG which will output diagnostics and warnings which can help to debug certain errors.

Examples

Initialize the submodules to include external dependencies:

git submodule init
git submodule sync
git submodule update

Install libuv:

sudo apt install libuv1-dev

Go to examples/devices/ and run make.

Tests

The tests directory contains a series of unit and integration tests. They can be compiled and run (under linux) by running

make

in the tests directory.

Documentation

libaes70 is documented using doxygen. The documentation can be built by running make doc in the top level directory.

DNS-SD

The AES70 standard defines that compliant implementations must use DNS-SD for device discovery. This library does not itself contain a DNS-SD implementation, instead use implementations available on the target platforms.

For instance,

  • lwIP contains an MDNS/DNS-SD implementation which can be used for embedded platforms,
  • most linux distributions contain MDNS responders such as avahi and
  • windows and MaxOS have bonjour.

For testing setups libaes70 contains a simple MDNS responder written in NodeJS which can be found in tools/mdns_wrapper. It is designed to be used together with the example devices found under examples/devices/.