Example: Mtp Controlengine

This example introduces you to the basic ControlEngine design pattern that will allow your MTP CE to interact with a process.

Overview

Semodia tends to write a lot of MTP control engines (CE for brevity). After trying several approaches, an architectural software design pattern that was SOLID, easy to replicate and robust was established. This “CE pattern” looks like this:

@startuml
    set namespaceSeparator ::

    namespace controlengine::native {
        interface ITask {
            #start()
            #iterate()
            #stop()
        }
        abstract MtpControlEngine
    }

    class ControlEngine implements controlengine::native::ITask
    class ControlEngine extends controlengine::native::MtpControlEngine
    {
        mtp : mtp::ModuleTypePackage
        server : reflection::opcua::server
        globalDataAssembly : any mtp::DataAssembly
        ..
        + initializeMtpContents();
        + update();
    }

    class ProcessController implements controlengine::native::ITask
    {
        drivers : dataio
        --
        +openValve(bool)
        +isValveOpen() : bool
    }

    interface IServiceHandler
    {
        + initializeMtpContents(mtp);
        + update();
    }

    class ServiceHandler implements IServiceHandler
    {
        serviceParameter : any mtp::ServiceParameters
        parameter : any mtp::ProcedureParameters
        executing() : ProcedureCallback
        starting() : ProcedureCallback
        completing() : ProcedureCallback
    }

    ControlEngine "1" -- "n" ServiceHandler : > owns

    ProcessController -- IServiceHandler : < uses
    ProcessController -- ControlEngine   : < uses
@enduml

Let’s review the main components before we move on:

  1. The ControlEngine handles everything MTP related, such as updating the MTP, updating DataAssemblies or updating services. This is where MTP-code happens! The ControlEngine owns the OPC UA server instance and the MTP instance. An abstract class parent will handle any routine stuff, like reflection.

  2. ServiceHandlers bundle all resources (parameters, callbacks) that are required by service and procedure callbacks. Whenever a MTP procedure does something, the callback can access all resources that are in the service handler.

  3. The ProcessController will implement any driver and I/O related code. It is shared with the CE and the ServiceHandlers, so anyone can use the ProcessController to request I/O Operations. The ProcessController should provide an interface that is geared towards the service requirements, so we would suggest you start your implementation with the CE and Handlers and leave this class for last.

Example application

It’s easier to picture what a CE does if we have an application - don’t worry, we’ll keep it simple:

  • Our example application will “simulate” a tank with two binary valves (input and output) and an analog level sensor.

  • One valve will allow 1 unit of water to enter the tank per unit of time, the other will drain 1 unit of water per time.

../../../_images/ditaa-f8c07325bed9fd9eb0c885c36a4de65d023354f1.png

Our process will also have a general “fault” signal, generated by a fictitious underlying controller, to indicate stuff like leakages or stuck valves. Our service should not start as long as this signal is present.

Our MTP CE should:

  • Use an AnaView to show the level sensor in % of tank capacity - 0 is empty, 100 is full.

  • Use one service “FillTankService” with one procedure
    • A BinServParam as ProcedureParameter defines if we fill or drain the tank

    • The procedure has to ProcessValueOuts - two BinViews that reflect the input and output valves

    • The procedure auto-completes when the tank level indicates full or empty

Here’s a definition table for our MTP - this is what you will usually either get from the client or have derived with your process experts:

Service:

FillTankService

Service Parameters:

Procedures:

1:LiquidProcedure

2:WeDontHaveAnotherProcedure

Procedure Parameters:

  • Fill/~Drain (BinServParam)

Process Input Values:

Process Output Values:

  • InletOpen (BinView)

  • OutletOpen (BinView)

Report Values:

EXECUTE self-completing:

yes

State

Transition

IDLE

START

no fault indicated

STARTING

sc

do:

Close Outlet Open Inlet

then:

complete

EXECUTING

if (failure):

goto HOLDING

else if(level != target):

do nothing

else:

complete

COMPLETE

n/a

COMPLETING

sc

do:

Close Outlet Close Inlet

complete

COMPLETED

PAUSE

PAUSING

sc

like COMPLETING

PAUSED

RESUME

RESUMING

like STARTING

HOLD

fault indicated

HOLDING

sc

like COMPLETING

HELD

UNHOLD

no fault indicated

UNHOLDING

like STARTING

STOP

STOPPING

sc

like COMPLETING

STOPPED

ABORT

ABORTING

sc

like COMPLETING

ABORTED

RESET

RESETTING

sc

Table: Methodic listing of the a MTP service behavior with pseudo-code. For transitions, the description states when the transition is enabled. For states, it describes how they interact with the process

Note that this MTP is of course entirely constructed for this example; we might just as well have used two separate procedures for filling/draining instead of the parameter… or even two services… or a level control parameter…

This example was built to demonstrate

  1. How to create services with procedure callbacks

  2. How to insert Procedure or Service parameters

  3. How to exert control over your process

  4. How to update parameters to/from the process as required

For a better understanding you might want to check out the simulation before getting deeper into the documentation, as you will see the desired outcome.

Software PreWork

Before we dive into our actual CE, let’s start of with some fundamental includes and infrastructure.

We will be using some terminal I/O, so we will create some rudimentary (and quite local to this example (‘static’)) IO functions first.

Warning

CENA has a logging API

This is a quick-and-dirty solution so we don’t need to explain our CENA logging API… In a real application, we would use the CENA logging API.

#include <string>
#include <iostream>
#include <chrono>
#include <thread>

void log(const std::string& prefix, const std::string& msg)
{
    std::cout << prefix << " " << msg << std::endl;
    return;
}

static void debug(const std::string& msg) { return log("DEBUG", msg); }
static void info(const std::string& msg)  { return log("INFO ", msg); }
static void error(const std::string& msg) { return log("ERROR", msg); }

First of, take care of signal handlers so we can CTRL-C out of our application. Note the extern-C - these are C headers and we don’t want the C++ compiler to name-mangle their definitions.

extern "C"
{
    #include <unistd.h>
    #include <signal.h>
}

The run variable (limited to this file) will control our main loop & allows the signal handler to terminate our program.

static bool run = true;
static void stopHandler([[maybe_unused]] int sign)
{
    run = false;
    return;
}

The Process Controller

In this example, we will create the ProcessController first - it’s a dummy class with no real functionality.

The ProcessController Interface

The following interface specifies what our CE will need to see to interact with the process.

class IProcessController
{
public:
    IProcessController() = default;
    virtual ~IProcessController() = default;

We need to evaluate the underlying controllers fault signal to find out if it is safe to use our tank… return true if a fault is detected, false otherwise

virtual bool isFaultPresent() = 0;

Allow the process controller to open/close our valve (inlet)

virtual void setInletValveOpen(const bool& open) = 0;

Allow the process controller to open/close our valve (outlet)

virtual void setOutletValveOpen(const bool& open) = 0;

To feed our BinView ReportValue, the CE needs to know the status of the inlet valve

virtual bool isInletValveOpen() const = 0;

To feed our other BinView ReportValue, the CE needs to know the status of the outlet valve

virtual bool isOutletValveOpen() const = 0;

To feed our AnaView, the CE needs to know the level status of the tank in %

    virtual float getFillLevelInPercent() const = 0;
};

High- and Low level control

The process control approach shown here allows the services to state how they want the process to act. The service must now specify all the control details about what valve to open/close and when. This is low-level control in the service.

That is ok, but there’s an alternate approach.

You might simplify services considerably by just stating that “a service fills the tank and is done when tank is full”. IProcessController would have three key functions: fillTank() and drainTank(), as well as operationComplete().

These would handle low-level control (which valves to open when), while the CE handles High-Level control. The ProcessController could be tested to assure it does indeed fill and drain as expected. This approach is more in accord with the Single Responsibility Principle, i.e. the ProcessController actually controlling the process as opposed to ValveControllerWithFillLevelMetering

Which approach you choose is up to you.

The ProcessController / Process Simulator

In terms of “process simulation”, we will use std::chrono to simulate how much liquid flows into our tank per unit of time

#include <chrono>

Also, this is the first time we encounter our task API; it is explained in detail in another tutorial.

#include <memory>
#include "tasking/BasicTaskLoopTask.hpp"
#include "locking/Lock.hpp"
namespace cena = semodia::controlengine::native;
using typename cena::osal::tasking::BasicTaskLoopTask;

The process controller itself doubles as a rudimentary process simulator.

In a real application, the ProcessController decouples CE and process. When executed by a thread or “iterated”

in a task loop, it should

  • Update any parts of the process modified by the CENA (ProcessController –> Drivers)

  • Update any internal representation of the process state (ProcessController <– Drivers)

Warning

Blocking IO

Unless it is trivial, never let any IProcessController interface function block or wait for a driver. This would stall the CE and any communications associated with it. Compared to OPC UA, most process interfaces are really, really slow.

We don’t have a real process to control or get data from. But we can simply use Chrono and some magic to simulate filling and draining our tank.

Tip

Improved testing

The CE won’t know that the process is real or not - from the CE’s standpoint, this could equally well be the real deal! This makes testing CE’s in this pattern a delight, as your tests can control a MockProcessController to check up on the CE’s actions.

class ProcessController : public IProcessController, public BasicTaskLoopTask
{
private:

The tankCapacity attribute represent units of liquid we can hold

const float tankCapacity;

The tankContents variable defines how much fluid is in the tank at this time

float tankContents;

The flowrate states how much liquid can enter or leave our virtual tank per second.

const float flowRate; // in liquid units/second

Create a rudimentary model of our input/output values: true is open, false is closed

bool inletValveOpen;
bool outletValveOpen;

We know our fill rate in units/second… record when iterate() was last called to determine how much liquid entered or left our tank

std::chrono::microseconds lastUpdate;

We also create a helper function to convert our timestamp to seconds (so we can hide chrono)

long getMicroSecondsElapsedSinceLastUpdate()
{
    return ((std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now().time_since_epoch()) - lastUpdate).count());
}

And let’s use another helper function to update our timestamp

    void markLastUpdateNow()
    {
        lastUpdate = std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now().time_since_epoch());
    }
protected:

Our iteration function (part of our task) is where we would usually update drivers with our process image

and read back values from the drivers into our process image. We’ll just “simulate” :)

    virtual void iterate() override
    {
        // Get the time perioid that passed since the last iteration
        const auto dt = this->getMicroSecondsElapsedSinceLastUpdate();
        const float seconds = static_cast<float>(dt) / 1000000.f;
        // Fill and/or drain. Yes. We can do both. We are THAT GOOD!
        if(inletValveOpen)
        {
            this->tankContents += (this->flowRate) * seconds;
        }
        if(outletValveOpen)
        {
            this->tankContents -= (this->flowRate) * seconds;
        }

        // Prevent overfilling and underdraining:
        if(this->tankContents > this->tankCapacity)
        {
            this->tankContents = this->tankCapacity;
        }
        else if (this->tankContents < 0)
        {
            this->tankContents = 0;
        }

        // Update the timestamp
        this->markLastUpdateNow();
        return;
    }

public:

In terms of initialization of our ProcessController, the capacity and flow rate are considered

“structural parameters” and are constants. We shall always initialize an empty vessel with closed valves.

ProcessController(const float& tankCapacity, const float& flowRate)
    :   IProcessController(),
        BasicTaskLoopTask(std::make_unique<cena::osal::locking::Lock>()),
        tankCapacity(tankCapacity < 0 ? 0 : tankCapacity),
        tankContents(0),
        flowRate(flowRate < 0 ? 0 : flowRate),
        inletValveOpen(false),
        outletValveOpen(false)
{
    markLastUpdateNow();
    return;
}

virtual ~ProcessController()
{
    return;
}

It is usually a very, very bad idea to copy a process controller with all its stateful drivers. This might

result in drivers being instructed to do things multiple times or in contradicting ways… we suggest you prevent copies of ProcessControllers.

ProcessController(const ProcessController&) = delete;
ProcessController& operator=(const ProcessController&) = delete;

The required interface functions should be fairly straight-forward at this point:

We don’t really have any fault-ability right now… this is solely here to show you

how to go from EXECUTE to HOLD on an error. Take a look at automatically going to HOLD on an error

bool isFaultPresent() override
{
    return false;
}

The input parameter/value of setInletValveOpen() is copied to our internal boolean

virtual void setInletValveOpen(const bool &open) override
{
    this->inletValveOpen = open;
    return;
}

The input parameter/value of setOutletValveOpen() is also just copied to our internal boolean

virtual void setOutletValveOpen(const bool &open) override
{
    this->outletValveOpen = open;
    return;
}

The inlet valve state is simply a copy of our own internal boolean; a real ProcessController might however

have a valve with separate feedback. In that case we would read the feedback in ProcessController::iterate() so that we not had to include any blocking IO here.

virtual bool isInletValveOpen() const override
{
    return this->inletValveOpen;
}

The outlet valve state is simply a copy our own internal boolean; a real ProcessController might however

have a valve with separate feedback.

virtual bool isOutletValveOpen() const override
{
    return this->outletValveOpen;
}

The tank fill level in percent is easy to calculate from our current contents and capacity

    virtual float getFillLevelInPercent() const override
    {
        return 100.f * (this->tankContents/this->tankCapacity);
    }
};

Warning

Multi-Service can mean Multi-Threading!

In a real-world application, multiple services will share a ProcessController. Simultaneous access might wreak havoc on your drivers. We highly suggest you introduce the cena::osal::ILockable class to your ProcessController to provide thread safety.

The Service Handler

Before we get to the CE, we need ServiceHandlers.

ServiceHandlers contain all MTP parameters, ReportValues, ProcessValueIns and ProcessValuesOuts that a service needs. They also house any callbacks and they initialize the MTP’s service representation.

This obviously means that this is the point where we need to introduce the MTP SDK to our example:

#include "mtp/ModuleTypePackage.hpp"
namespace mtp = cena::model::mtp;

Service Handler Interface

This Interface is used by the ControlEngine class to interact with service handlers that hold service and procedure parameters

class IServiceHandler
{
public:
    IServiceHandler() = default;
    virtual ~IServiceHandler() = default;

Invoked by ControlEngine as part of regular iteration; may include reading/updating Report- or ProcessOut values

virtual void update() = 0;

initializeMtpContents is invoked by ControlEngine to set up any mtp related contents, such as service parameters or nodeIds.

This removes the need to initialize the service directly in the CE.

    virtual void initializeMtpContents(mtp::ModuleTypePackage &mtp) = 0;
};

FillTankServiceHandler

Let’s dive into our actual service handler and begin by including what we will need from CENA MTP Model Core:

#include <memory>
#include "mtp/BinView.hpp"
#include "mtp/BinServParam.hpp"
#include "mtp/Service.hpp"
#include "mtp/ServiceSet.hpp"
#include "mtp/ServiceControl.hpp"
#include "mtp/Procedure.hpp"
#include "mtp/ServiceSourceModeBaseFunction.hpp"
#include "mtp/ServiceOperationModeBaseFunction.hpp"

namespace mtp = semodia::controlengine::native::model::mtp;

We could solve this more elegantly, but just for this example lets declare our namespace globally to be consistent in CE and Service Handlers.

static const std::string mtpStaticContentNamespaceName("https://yourNamespace.com");

The FillTankServiceHandler class will have to handle everything related to the service.

“Everything related” includes:

  • Owning all parameters, processes in/outs and reports values

  • Configuring the service in the mtp

  • Owning all callbacks, i.e. business logic included in the MTP’s states

class FillTankServiceHandler : public IServiceHandler
{
private:

to simplify access to the mtp::Service class, we will store a pointer to the service while initializing the MTP

Note how everything in this class is shared?

One pointer instance always resides in the MTP, but a second instance is kept in the handlers for convenience. Alternatively, we could - if we wanted to - also “retrieve” these classes from the MTP on each use.

    std::shared_ptr<mtp::Service> liquidService = nullptr;

protected:

Remember how we said “The ServiceHandler owns the parameters”? Here they are:

The ProcedureParameter that instructs us to fill or drain the tank when going into the “starting” state of our service

std::shared_ptr<mtp::BinServParam> procedureModeFillTank;

The inlet valve state (ProcessValueOut) as reported by the process controller; updated by FillTankServiceHandler::update()

std::shared_ptr<mtp::BinView> inletValveState;

The outlet valve state (ProcessValueOut) as reported by the process controller; updated by FillTankServiceHandler::update()

std::shared_ptr<mtp::BinView> outletValveState;

If you had any ServiceConfigurationParameters, ReportValues or ProcessValueIns, they would also be declared right here.

Tip

Why not declare all Data Assemblies in the CE and share them around?

From the CE and Handler standpoint, it does not matter where the instances reside. But the ServiceHandler must access them, while the CE has no use for the parameters. Placing unneeded DataAssemblies in the CE/InstanceList (instead of in the services) and then sharing them around reduced the memory footprint by allowing reuse, but increases software complexity.

This is the process controller that this class needs to interact with the CE and all other service handlers

    std::shared_ptr<ProcessController> process;

public:

We need to initialize our member parameters in the constructor.

For convenience, we will sync our BinServParam to the state; i.e. we don’t have to control offline/automatic/manual and external/internal - they are copied from the service

FillTankServiceHandler(std::shared_ptr<ProcessController> process)
    :   procedureModeFillTank(std::make_shared<mtp::BinServParam>("fillTank", "Instruction to fill or drain the tank", true, false, "True means fill", "False means drain")),
        inletValveState(std::make_shared<mtp::BinView>("inletValveState", "Valve controlling liquid going into the tank", false, "True means open", "False means closed")),
        outletValveState(std::make_shared<mtp::BinView>("outletValveState", "Valve controlling liquid going into the tank", false, "True means open", "False means closed")),
        process(process)

{
    return;
}
virtual ~FillTankServiceHandler()
{
    return;
}

Initializing the MTP

We need to initialize the MTP to include our service. To that end, we need to create the service and service control stuff in the MTP, then include pointers to our parameters.

virtual void initializeMtpContents(mtp::ModuleTypePackage &mtp) override
{

Before we start with the service, let’s assign some static nodeIds to our parameters; these need to match the parameters as defined in the MTP so the POL can address them directly using OPC UA.

You can assign parameters to any ReadDataItem or ReadWriteDataItem in the MTP Model Core like so: (yes, you may also use ‘i=’)

this->inletValveState->getV()->setFixedOpcUaNodeId("s=InletValveState_V", mtpStaticContentNamespaceName);
this->inletValveState->getWQC()->setFixedOpcUaNodeId("s=InletValveState_WQC", mtpStaticContentNamespaceName);
this->outletValveState->getV()->setFixedOpcUaNodeId("s=OutletValveState_V", mtpStaticContentNamespaceName);
this->outletValveState->getWQC()->setFixedOpcUaNodeId("s=OutletValveState_WQC", mtpStaticContentNamespaceName);
this->procedureModeFillTank->getVInt()->setFixedOpcUaNodeId("s=ProcedureModeFillTank_VInt", mtpStaticContentNamespaceName);
this->procedureModeFillTank->getVExt()->setFixedOpcUaNodeId("s=ProcedureModeFillTank_VExt", mtpStaticContentNamespaceName);
this->procedureModeFillTank->getVOp()->setFixedOpcUaNodeId("s=ProcedureModeFillTank_VOp", mtpStaticContentNamespaceName);
this->procedureModeFillTank->getVOut()->setFixedOpcUaNodeId("s=ProcedureModeFillTank_VOut", mtpStaticContentNamespaceName);
this->procedureModeFillTank->getVReq()->setFixedOpcUaNodeId("s=ProcedureModeFillTank_VReq", mtpStaticContentNamespaceName);
this->procedureModeFillTank->getApply()->getApplyEn()->setFixedOpcUaNodeId("s=ProcedureModeFillTank_ApplyEn", mtpStaticContentNamespaceName);
this->procedureModeFillTank->getApply()->getApplyOp()->setFixedOpcUaNodeId("s=ProcedureModeFillTank_ApplyOp", mtpStaticContentNamespaceName);
this->procedureModeFillTank->getApply()->getApplyExt()->setFixedOpcUaNodeId("s=ProcedureModeFillTank_ApplyExt", mtpStaticContentNamespaceName);
this->procedureModeFillTank->getApply()->getApplyInt()->setFixedOpcUaNodeId("s=ProcedureModeFillTank_ApplyIn", mtpStaticContentNamespaceName);

Next, we need to create and configure our service in the mtp, populate each service and attach our parameters.

Take your MTP Definition Table as a reference and stick to this template:

For each service, do:

  • create the service

  • add any service configuration parameters

  • For each procedure:

    • Create the procedure

    • Add parameters

    • Assign static nodeIds to procedure elements (as defined in the MTP file)

  • Assign static nodeIds to service control elements (as defined in the MTP file)

mtp.getControl()->createService("FillTankService");
if( mtp::MtpStatusCode::GOOD == mtp.getControl()->getServiceByName("FillTankService", liquidService))
{
    debug("Created FillTankService");

If this service had ConfigurationParameters, this would be the place to put them. These parameters would be considered “shared” amongst all procedures.

We have a ProcedureParameter further down, which works in exactly the same way as the ConfigurationParameters would.

Let’s continue by creating our procedure.

A procedure is represented by an ID, which the POL uses to select and with 0 meaning “no procedure”. We don’t assign this ID from userspace; it is assigned incrementally by the stack.

std::uint32_t procedureIdAssignedByService = 0;
mtp::MtpStatusCode retval = liquidService->createProcedure("FillTank", true,  procedureIdAssignedByService,
     [&](mtp::ProcedureHealthView& commandInfo)
    {

Procedures determine which transitions within a service are permitted, which they do by updating their CommandInfo. We have to define which transitions are permitted, an when. Note that all procedures always update this value, even if they are not selected by the POL or running.

The default - if we don’t change anything here - is to allow all transitions.

We want to update which states the POL can request. In our MTP Definition Table, there is only one major switch: Fault or not fault.

commandInfo.setCommandEnable(mtp::ServiceCommandId::COMPLETE, true);
commandInfo.setCommandEnable(mtp::ServiceCommandId::ABORT, true);
commandInfo.setCommandEnable(mtp::ServiceCommandId::HOLD, true);
commandInfo.setCommandEnable(mtp::ServiceCommandId::STOP, true);
commandInfo.setCommandEnable(mtp::ServiceCommandId::RESET, true);
commandInfo.setCommandEnable(mtp::ServiceCommandId::START, !(this->process->isFaultPresent()));
commandInfo.setCommandEnable(mtp::ServiceCommandId::HOLD, this->process->isFaultPresent());
commandInfo.setCommandEnable(mtp::ServiceCommandId::UNHOLD, !(this->process->isFaultPresent()));

For this example, we will also interdict the use of PAUSE and RESTART, like so:

        commandInfo.setCommandEnable(mtp::ServiceCommandId::RESTART, false);
        commandInfo.setCommandEnable(mtp::ServiceCommandId::PAUSE, false);
        return;
    }
);

If your MTP has a fixed ID for this procedure, it pays off to check this procedure here

if(1 != procedureIdAssignedByService)
{
    error("We expected ProcedureId 1... but the CENA has assigned another ID");
}

// Procedure fillTank (intended to be selectable using MTP procedure ID 1 and to be self-completing)
std::shared_ptr<mtp::Procedure> liquidProcedure = nullptr; // We'll need this
if(mtp::MtpStatusCode::GOOD == retval && mtp::MtpStatusCode::GOOD == liquidService->getProcedureByName("FillTank", liquidProcedure))
{
    // Add the `ProcedureParameters` (which work exactly like `ConfigurationParameters`)
    liquidProcedure->createProcedureParameter("FillMode", this->procedureModeFillTank);

    // add ReportValues
    // ... if we had any, which we don't

    // ProcessValueOut
    liquidProcedure->createProcessValueOut(this->inletValveState);
    liquidProcedure->createProcessValueOut(this->outletValveState);

    // add ProcessValueIns
    // ... if we had any, which we don't
}
else
{
    error("MTP Initialization: Creating procedure 'FillTank' failed");
}

// << If we had more procedures, they would go here

Now we define the node ids for the Service, ServiceControl and ProcedureHealthView DataAssemblies. If we don’t specify the node ids statically, they will be assigned arbitrary at runtime. As we want them to match the node ids defined in the MTP file, we set them explicitly, otherwise the POL could not address the nodes correctly.

    liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateChannel()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateChannel", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateAutAct()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateAutAct", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateOpAct()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateOpAct", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateOffAct()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateOffAct", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateOpOp()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateOpOp", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateAutOp()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateAutOp", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateOffOp()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateOffOp", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateOpAut()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateOpAut", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateAutAut()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateAutAut", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateOffAut()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateOffAut", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getCommandEn()->setFixedOpcUaNodeId("s=FillTankServiceControl_CommandEn", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getCommandOp()->setFixedOpcUaNodeId("s=FillTankServiceControl_CommandOp", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getCommandInt()->setFixedOpcUaNodeId("s=FillTankServiceControl_CommandInt", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getCommandExt()->setFixedOpcUaNodeId("s=FillTankServiceControl_CommandExt", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getProcedureCur()->setFixedOpcUaNodeId("s=FillTankServiceControl_ProcedureCur", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getProcedureOp()->setFixedOpcUaNodeId("s=FillTankServiceControl_ProcedureOp", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getProcedureInt()->setFixedOpcUaNodeId("s=FillTankServiceControl_ProcedureInt", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getProcedureExt()->setFixedOpcUaNodeId("s=FillTankServiceControl_ProcedureExt", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getStateCur()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateCur", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getProcedureReq()->setFixedOpcUaNodeId("s=FillTankServiceControl_ProcedureReq", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getProcParamApply()->getApplyOp()->setFixedOpcUaNodeId("s=FillTankServiceControl_ProcParamApplyOp", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getProcParamApply()->getApplyEn()->setFixedOpcUaNodeId("s=FillTankServiceControl_ProcParamApplyEn", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getProcParamApply()->getApplyInt()->setFixedOpcUaNodeId("s=FillTankServiceControl_ProcParamApplyInt", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getProcParamApply()->getApplyExt()->setFixedOpcUaNodeId("s=FillTankServiceControl_ProcParamApplyExt", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getConfigParamApply()->getApplyOp()->setFixedOpcUaNodeId("s=FillTankServiceControl_ConfigParamApplyOp", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getConfigParamApply()->getApplyEn()->setFixedOpcUaNodeId("s=FillTankServiceControl_ConfigParamApplyEn", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getConfigParamApply()->getApplyInt()->setFixedOpcUaNodeId("s=FillTankServiceControl_ConfigParamApplyInt", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getConfigParamApply()->getApplyExt()->setFixedOpcUaNodeId("s=FillTankServiceControl_ConfigParamApplyExt", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getWQC()->setFixedOpcUaNodeId("s=FillTankServiceControl_WQC", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getOSLevel()->setFixedOpcUaNodeId("s=FillTankServiceControl_OSLevel", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getPosTextID()->setFixedOpcUaNodeId("s=FillTankServiceControl_PosTextID", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getInteractQuestionID()->setFixedOpcUaNodeId("s=FillTankServiceControl_InteractQuestionID", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getInteractAnswerID()->setFixedOpcUaNodeId("s=FillTankServiceControl_InteractAnswerID", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getInteractAddInfo()->setFixedOpcUaNodeId("s=FillTankServiceControl_InteractAddInfo", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getReportValueFreeze()->setFixedOpcUaNodeId("s=FillTankServiceControl_ReportValueFreeze", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getServiceSourceMode()->getSrcChannel()->setFixedOpcUaNodeId("s=FillTankServiceControl_SrcChannel", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getServiceSourceMode()->getSrcExtAut()->setFixedOpcUaNodeId("s=FillTankServiceControl_SrcExtAut", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getServiceSourceMode()->getSrcIntAut()->setFixedOpcUaNodeId("s=FillTankServiceControl_SrcIntAut", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getServiceSourceMode()->getSrcIntOp()->setFixedOpcUaNodeId("s=FillTankServiceControl_SrcIntOp", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getServiceSourceMode()->getSrcExtOp()->setFixedOpcUaNodeId("s=FillTankServiceControl_SrcExtOp", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getServiceSourceMode()->getSrcIntAct()->setFixedOpcUaNodeId("s=FillTankServiceControl_SrcIntAct", mtpStaticContentNamespaceName);
    liquidService->getReferencedServiceControl()->getServiceSourceMode()->getSrcExtAct()->setFixedOpcUaNodeId("s=FillTankServiceControl_SrcExtAct", mtpStaticContentNamespaceName);

    liquidProcedure->getReferencedProcedureHealthView()->getWQC()->setFixedOpcUaNodeId("s=FillTankHV_WQC", mtpStaticContentNamespaceName);
    liquidProcedure->getReferencedProcedureHealthView()->getCommandInfo()->setFixedOpcUaNodeId("s=FillTankHV_CommandInfo", mtpStaticContentNamespaceName);
}
else
{
    error("Failed to create FillTankService");
}

Cool. That wasn’t too difficult.

We now are left some critical tasks that configure the CE’s behavior:

  1. For each Service:
    • Optional: Set up ApplyEn for each ConfigurationParameter

    • Optional: Set up ApplyCallbacks for each ConfigurationParameter

  1. For each Procedure:
    • We need to attach our procedure callbacks

    • Optional: Set up ApplyEn for each ProcedureParameter

    • Optional: Set up ApplyCallbacks for each ProcedureParameter

  2. We need to set the default controller (who may change offline/automatic/operator modes) of Services

  3. We need to set the default controller (who may change offline/automatic/operator modes) of

    1. service configuration parameters

    2. procedure parameters

  4. If required, set sync modes and other configuration stuff for the parameters

Let us start with the callbacks. Note that you by no means need to attach a callback to each state - if you leave a state out, CENA will automatically use a proxy that does nothing and always completes immediately. You can also bind the same callback multiple times and even declare lambda operations right here. It’s up to you.

Again, using the MTP Definition Table as a reference makes this easy:

std::shared_ptr<mtp::Procedure> liquidProcedure = nullptr; // We'll need this
if(mtp::MtpStatusCode::GOOD == liquidService->getProcedureByName("FillTank", liquidProcedure))
{
    liquidProcedure->attachUserCallback(mtp::ServiceStateId::STARTING, std::bind(&FillTankServiceHandler::starting, this, std::placeholders::_1));
    liquidProcedure->attachUserCallback(mtp::ServiceStateId::RESUMING, std::bind(&FillTankServiceHandler::starting, this, std::placeholders::_1));
    liquidProcedure->attachUserCallback(mtp::ServiceStateId::UNHOLDING, std::bind(&FillTankServiceHandler::starting, this, std::placeholders::_1));
    liquidProcedure->attachUserCallback(mtp::ServiceStateId::EXECUTE, std::bind(&FillTankServiceHandler::executing, this, std::placeholders::_1));
    liquidProcedure->attachUserCallback(mtp::ServiceStateId::COMPLETING, std::bind(&FillTankServiceHandler::completing, this, std::placeholders::_1));
    liquidProcedure->attachUserCallback(mtp::ServiceStateId::PAUSING, std::bind(&FillTankServiceHandler::completing, this, std::placeholders::_1));
    liquidProcedure->attachUserCallback(mtp::ServiceStateId::HOLDING, std::bind(&FillTankServiceHandler::holding, this, std::placeholders::_1));
    liquidProcedure->attachUserCallback(mtp::ServiceStateId::STOPPING, std::bind(&FillTankServiceHandler::completing, this, std::placeholders::_1));
    liquidProcedure->attachUserCallback(mtp::ServiceStateId::ABORTING, std::bind(&FillTankServiceHandler::completing, this, std::placeholders::_1));

ProcedureParameters (and ConfigurationParameters) contain a single ParameterElement.

The POL can not change parameters whenever it likes - we have to allow Updates by settings each parameters “enable” bit. We want the ProcedureParameter to be changeable in IDLE so we can use it in STARTING (remember: we turned RESTART of). The “wrapper” around the actual parameter provides us with an alternative: We can automate the handling of ApplyEn based on the service state of a selected or active procedure.

@startuml
class ProcedureParameter
{
    +name : string
    +ReferencesDataAssembly : *ParameterElement
}
note left of ProcedureParameter
    Provides ApplyEn automation
    tied to service states
end note

ProcedureParameter *- ParameterElement : "ReferencesDataAssembly"

class ParameterElement
{
    +TagName : string
    +ApplyEn : bool
    ...
}

note right of ParameterElement
    Handles Apply-Logic
    Knows nothing about which procedure
    it belongs to or what state the service
    is in
end note

class BinServParam extends ParameterElement
{
    VInt : bool
    VExt : bool
    VOp : bool
    V : bool
}

note right of BinServParam
    The actual value in the handler
    that we want to use in
    STARTING.
end note
@enduml

Step 1: We don’t want updates to be allowed initially.

this->procedureModeFillTank->allowValueUpdates(false);

If you do not pass a name parameter, i.e. liquidProcedure->createProcedureParameter(this->procedureModeFillTank);, the stack would simply use procedureModeFillTank’s TagName.

Using this form of ApplyEn-automation is entirely optional! We could simply activate and deactivate this in the IDLE and STARTING callbacks…

std::shared_ptr<mtp::ProcedureParameter> procedureParameter;
if(mtp::MtpStatusCode::GOOD == liquidProcedure->getProcedureParameterByName("FillMode", procedureParameter))
{

To demonstrate, we will instruct the stack to automatically enable updates for the ProcedureParameter in IDLE, but disable it when entering STARTING.

Note that for every state not explicitly mentioned in this callback, the ApplyEn bit remains unchanged

    procedureParameter->setStateBasedUpdateEnable(mtp::ServiceStateId::IDLE, mtp::StateBasedUpdateEnableSetting::EnabledByState);
    procedureParameter->setStateBasedUpdateEnable(mtp::ServiceStateId::STARTING, mtp::StateBasedUpdateEnableSetting::DisabledByState);
}

One last word about ProcedureParameters and ConfigurationParameters:

You will find that there are two general use cases for these.

  1. Most of the time, you will simply read and use the parameter values in the callbacks - for example to see if a fill level of a tank is reached. For these uses the Parameter is now pretty much set up.

  2. Occasianally, you will need to do something only when the parameter is changed. This tends to be the case for parameters to send to smart field devices, like mass flow sensors. For these kinds of uses we need to have some form of “onChange” trigger/event callback.

Let’s quickly demonstrate the callbacks, even though we won’t use them in this example. Note that we are passing the lamda-function its own shared_ptr-copy of our parameter, ensuring that this callback won’t crash on the off chance it is invoked without this/handler existing anymore.

    auto procedureModeFillTankReference = this->procedureModeFillTank;
    this->procedureModeFillTank->afterApplyDo([procedureModeFillTankReference](){
        info("The mode of filling the tank was changed to `" + std::to_string((int) (procedureModeFillTankReference->getCurrentSetpoint())) + "`");
        return;
    });
}

Next comes the default controller for our service: We should put the POL in charge (we/CENA can grab control) whenever it feels like it, as we will see in HOLD.

if(liquidService != nullptr)
{
    liquidService->requestModeControl(false);
}

Finally, we will set up the parameters. For each of our parameters, this entails

  • Assigning the controller (as with the service)

  • Optionally: Making the parameter sync

Instead of controlling the parameter via the POL, we want it synced to our service control mode. This makes the parameter be Operator-controlled when the service is operator-controller and automatic when the service is in automatic.

    this->procedureModeFillTank->requestModeControl(false);
    this->procedureModeFillTank->setSync(true);

    return;
}

Updating a service Handler

We need to initialize the MTP to include our service. To that end, we need to create the service and service control stuff in the MTP, then include pointers to our parameters.

Updating has 2 big parts:

  1. Updating our parameters either by reading or writing from/to the process

  2. Setting command enable bits to block/allow access to specific parts of our service’s state machine

The update() function updates MTP related contents periodically, i.e. it is not triggered by the

MTP Model Core on changes from the POL, but called as part of our task loop or thread handling the CE.

virtual void update() override
{

Handling our parameters follows a fixed pattern:

  1. Read all outputs from the process (ReportValues, ProcessValueOuts, potential Fbk in Service/ProcedureParameters)

  2. Write any inputs to the process (ProcessValueIns)

If you have error reporting in your process class - i.e. you get a StatusCode along with the value - you might also want to update the WQC of each value… we don’t have that, so we’ll just set them to ‘OK’.

    {
        // Note how [Bin/DInt/Ana/String]View-Types can update their VOut by assigning the appropriate type.
        // For ParameterElements, this would affect VFbk.
        *this->inletValveState = this->process->isInletValveOpen();
        this->inletValveState->setQualityCode(mtp::QualityCode::OK);

        *this->outletValveState = this->process->isOutletValveOpen();
        this->outletValveState->setQualityCode(mtp::QualityCode::OK);

        // << We don't have any ProcessValueIns to write to the process, but if we had some, they would go here
    }

    return;
}

The Service’s Business logic

In our entire service, we only need to specify “control logic” for a handful of states. These are rather straight forward to fill out since we have easy access to all our parameters and the process class.

Warning

Callbacks may be called asynchronously

The Model Core will invoke these callbacks whenever the MTP is updated and the service is in the appropriate state - this may mean that they can be called at a point in time you don’t control.

Note how each callback needs to report back if it is “completed” - this is of significance for all “sc” states and self-completing EXECUTING services, as returning true will make the internal state machine conclude this state.

void starting(bool& completed)
{
    debug("FillTankServiceHandler: LiquidProcedure: Starting");
    if (this->procedureModeFillTank->getCurrentSetpoint() == true)
    {
        // "True" is "fill"
        debug("Commencing fill");
        this->process->setInletValveOpen(true);
        this->process->setOutletValveOpen(false);
    }
    else
    {
        // "False" is "drain"
        debug("Commencing drain");
        this->process->setInletValveOpen(false);
        this->process->setOutletValveOpen(true);
    }

    completed = true;
    return;
}

void executing(bool& completed)
{
    debug("FillTankServiceHandler: LiquidProcedure: Executing");
    completed = false;

Most applications will state that they want EXECUTING to go to HOLDING when an error occurs. This is a bit counter-intuitive from the PEAs standpoint, as we assume that the POL is in control and - if it wants to - it can see the error and simply command us to HOLD. However, most applications deem this either to be too uncertain or not fast enough.

Warning

Safety critical control

The MTP and in turn the CENA are not designed to handle safety-critical applications! Ultimately, there would always be a controller underneath the CENA that handles interlocks, so going to HOLDING on our part is more of a gesture than a safety related issues.

In any case, to switch the command/issue own commands, we need to grab control of the service’s ServiceControl. This will likely be in Automatic/External or even Operator. We need it to be in Automatic/Internal to request state changes. We need to hand this control back AFTER we are done, i.e. in the HOLDING callback.

if(process->isFaultPresent())
{
    // Declare PEA as service controller and take the service to Automatic/Internal, then issue HOLD
    if(this->liquidService != nullptr)
    {
        this->liquidService->requestModeControl(true);
        this->liquidService->requestOperationMode(mtp::OperationModeId::Automatic, mtp::ServiceSourceModeId::Internal);
        this->liquidService->requestCommand(mtp::ServiceCommandId::HOLD);
    }
    return;
}

Note

A common misconception in MTP definitions is that we need to “declare” all parameters we want to use in the business logic as some form of parameter. As you see here, we don’t! We can interrogate our process just fine using the process class, without requiring a “fill level” parameter in the service.

This does indeed help to abstract the underlying process and keeps the MTP lean.

        const auto fillLevel = this->process->getFillLevelInPercent();
        debug("FillLevel: " + std::to_string(fillLevel) + " %");
        if (true == this->procedureModeFillTank->getCurrentSetpoint())
        {
            // "True" is "fill"
            completed = (fillLevel >= 100);
        }
        else
        {
            // "False" is "drain"
            completed = fillLevel <= 0;
        }


        return;
    }

    void completing(bool& completed)
    {
        debug("FillTankServiceHandler: LiquidProcedure: Completing");
        this->process->setInletValveOpen(false);
        this->process->setOutletValveOpen(false);

        completed = true;
        return;
    }

    void holding(bool& completed)
    {
        this->completing(completed);
        if (completed)
        {
            // Remember how EXECUTING had to wrestle away control from the POL? This is where we give it back
            // so that the POL can leave HELD once the error is cleared.
            if (this->liquidService != nullptr)
            {
                this->liquidService->requestModeControl(false);
            }
        }
        return;
    }
};

The Control Engine

The control engine ties a MTP instance to an OPC UA server instance. The control engine also houses all our service handlers as well as any “global” variables that are not required as part of a service.

Let’s grab what we need from the CENA and move straight into the class.

#include "controlengine/MtpControlEngine.hpp"
#include "timing/BasicTimer.hpp"
#include "opcua/open62541/OpcUaServerOpen62541.hpp"
#include "mtp/AnaView.hpp"
#include "mtp/PeaInformationLabel.hpp"

using typename cena::reflection::opcua::OpcUaServerOpen62541;
class ControlEngine : public mtp::MtpControlEngine
{
private:

The CENA manages the OPC UA Server instance, but the instance is provided by dependency injection

and is likely to be shared with other classes

std::shared_ptr<OpcUaServerOpen62541> opcuaServer; // Co-Owned by base class and reflection

The CE houses the processController, which it shares with the ServiceHandlers but also uses itself

std::shared_ptr<ProcessController> process;

The CE houses all our service handlers

FillTankServiceHandler liquidService;

The ControlEngine class is the parent class for any global DataAssemblies, i.e. the ones that are not directly tied to a procedure’s inner workings. This may include

  • simple diagnostic outputs

  • ActiveElements, such as valves and drives, which will also have their callbacks assigned in/by the CE

We want to be able to view the fill level of our tank. It is not critical to any service, but nice to

watch. So we will add it to the global scope and let ControlEngine::update() handle the logic.

    std::shared_ptr<mtp::AnaView> fillLevel;

protected:

The magic of the CE is hidden in its three task function, which will in essence drive the entire CE and MTP related logic.

Our base classes behavior needs to be explained a bit though:

MtpControlEngine::start( ) will create the MTP and invoke our overridden initializeMtpContents() (which will, in turn, invoke the service handlers initializeMtpContents()). MtpControlEngine will then proceed to reflect the MTP on OPC UA: That means that everything in the MTP at this point in time will automatically be re-created in OPC UA. Our base class handles this. We just need to stick to our initialization pattern.

MtpControlEngine::iterate( ) will perform any mtp related content updates for us, like invoking callbacks on ActiveElements or ticking the state logic of our services (which would in turn invoke callbacks in our service handlers). Our base class is a FrequencyLimitedTaskLoopTask: that means that we can invoke iterate() as often as we want, but the MTP Model Core logic will only tick every X ms - so this is the frequency our MTP business logic will be invoked with.

    void start() override
    {
        // This order is **very important**:
        // Call the server's start() first, because the control engine's start will create the mtp and reflect it!
        // So the server always needs to be started/initialized first!
        this->opcuaServer->doStart();

        this->MtpControlEngine::start();
    }

    void iterate() override
    {
        // The first update triggers the CE (and any service handler) to update their DataAssemblies from/to the
        // process handler.
        this->update();
        this->MtpControlEngine::iterate();
    }

    void stop() override
    {
        this->MtpControlEngine::stop();
        this->opcuaServer->doTerminate();
    }
public:

The constructor initializes the MtpControlEngine base class and accepts a server via dependency injection.

Obviously, we also need to initialize our Service Handlers, our fillLevel AnaView as well as any other global DataAssembly.

ControlEngine(std::shared_ptr<OpcUaServerOpen62541> opcuaServer, std::shared_ptr<ProcessController> process)
    :   mtp::MtpControlEngine( std::make_unique<cena::osal::locking::Lock>(), std::make_unique<cena::osal::timing::BasicTimer>(),
                               opcuaServer,
                               100, // Update only every 100ms
                               "ExamplePEA",
                               "BeverageProduction",
                               "https://semodia.com/aas/ExamplePEA/manual.pdf",
                               "123",
                               "Semodia GmbH",
                               "https://semodia.com",
                               "Product_C0DE",
                               "https://semodia.com/aas/ExamplePEA/666",
                               "666",
                               "3.0.0",
                               "Semodia GmbH",
                               "https://semodia.com",
                               "http://localhost:5000"),
        opcuaServer(opcuaServer),
        process(process),
        liquidService(process),
        fillLevel(std::make_shared<mtp::AnaView>("FillLevel", "The Tanks current fill level", 0, 0, 100, mtp::UnitCode::PERCENT))
{
    return;
};

virtual ~ControlEngine()
{
    return;
};

MTP and Process are linked statefully; this class MUST NOT be trivially copied

ControlEngine(const ControlEngine &other) = delete;
ControlEngine &operator=(const ControlEngine &rhs) = delete;

The initializeMtpContents() works quite similarly to what we encountered in the ServiceHandlers and has many of the same tasks:

  • For each global DataAssembly:
    • Add it to the mtp

    • Assign a static NodeId

  • Additionally, for each ActiveElement: Assign Callbacks

  • Invoke the initializer of each of our service handlers.

initializeMtpContents is invoked by our MtpControlEngine base class to set up the MTP.

At this point, MtpControlEngine has already created the mtp (which has become part of our protected scope)

virtual void initializeMtpContents() override
{
    // Our AnaView might need some static nodeIds
    this->fillLevel->getV()->setFixedOpcUaNodeId("s=fillLevel_V", mtpStaticContentNamespaceName);
    this->mtp->addDataAssembly(this->fillLevel);

    // Some parts of the PeaInformationLabel have to be available at runtime

    this->mtp->getPeaInformation()->getAssetId()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.AssetId", mtpStaticContentNamespaceName);
    this->mtp->getPeaInformation()->getComponentName()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.ComponentName", mtpStaticContentNamespaceName);
    this->mtp->getPeaInformation()->getDeviceClass()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.DeviceClass", mtpStaticContentNamespaceName);
    this->mtp->getPeaInformation()->getDeviceHealth()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.DeviceHealth", mtpStaticContentNamespaceName);
    this->mtp->getPeaInformation()->getDeviceManual()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.DeviceManual", mtpStaticContentNamespaceName);
    this->mtp->getPeaInformation()->getDeviceRevision()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.DeviceRevision", mtpStaticContentNamespaceName);
    this->mtp->getPeaInformation()->getHardwareRevision()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.HardwareRevision", mtpStaticContentNamespaceName);
    this->mtp->getPeaInformation()->getManufacturer()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.Manufacturer", mtpStaticContentNamespaceName);
    this->mtp->getPeaInformation()->getManufacturerUri()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.ManufacturerUri", mtpStaticContentNamespaceName);
    this->mtp->getPeaInformation()->getModel()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.Model", mtpStaticContentNamespaceName);
    this->mtp->getPeaInformation()->getProductCode()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.ProductCode", mtpStaticContentNamespaceName);
    this->mtp->getPeaInformation()->getProductInstanceUri()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.ProductInstanceUri", mtpStaticContentNamespaceName);
    this->mtp->getPeaInformation()->getRevisionCounter()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.RevisionCounter", mtpStaticContentNamespaceName);
    this->mtp->getPeaInformation()->getSerialNumber()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.SerialNumber", mtpStaticContentNamespaceName);
    this->mtp->getPeaInformation()->getSoftwareRevision()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.SoftwareRevision", mtpStaticContentNamespaceName);



    // Invoke each of our service handlers
    this->liquidService.initializeMtpContents(*this->mtp);
    debug("Control Engine's MTP is initialized");
    return;
}

As with the ServiceHandler, update() performs MTP-related updates and ticks our state machine. It also

invokes our ServiceHandler::update() as required.

    virtual void update()
    {
        // Update our data assemblies:
        *this->fillLevel = this->process->getFillLevelInPercent();
        if(*this->fillLevel > 100 || *this->fillLevel < 0)
        {
            this->fillLevel->setQualityCode(mtp::QualityCode::OUT_OF_SPECIFICATION);
        }
        else
        {
            this->fillLevel->setQualityCode(mtp::QualityCode::OK);
        }

        // Update each of our services:
        this->liquidService.update();
    };
};

The Main Application

The main application links all our components (ProcessController, ControlEngine) together. In a real-world scenario, it is responsible for choosing the specific implementations of any dependency-injected class, handles command-line-options and also constructs the logging-mechanism.

Every main application has 3 parts:

  1. System Setup (I/O configuration, logging, creating our classes)

  2. The main loop

  3. Shutdown and Cleanup

CENA is designed to be used in both multi-threaded and task-loop-type applications; we will only explore the task loop concept here. A threaded application would however not deviate from the above pattern at all. The main loop would simply not do much.

Let’s start with setting up our classes:

int main([[maybe_unused]] int argc, [[maybe_unused]] char** argv)
{
    signal(SIGINT, stopHandler);
    signal(SIGTERM, stopHandler);

    info("Welcome to our ControlEngine Example");

    auto process = std::make_shared<ProcessController>(10, 0.4); // ~ 20s till full or empty
    process->doStart();

    auto uaServer = std::make_shared<OpcUaServerOpen62541>(std::make_unique<cena::osal::locking::Lock>(), std::make_unique<cena::osal::locking::Lock>());
    auto controlEngine = std::make_shared<ControlEngine>(uaServer, process);
    controlEngine->doStart();

The loop

The following loop will update our control engine, perform any process I/O in a separate (non-blocking) step and update any MTP contents.

Note what happens in regard to the usleep-directive:

  • The process is updated every 1ms,

  • The controlEngine is updated every 1ms, but the MTPControlEngine underneath it is a time-limited task! So it will only invoke our business logic every 100ms, no matter how often we update it!

  • The OPC UA Server is probably the task that requires the highest rate of repetition and the sole reason we need to sleep in the tens-of-milliseconds-regime instead of hundreds-of-milliseconds.

    info("Running the control engine");
    while(run)
    {
        uaServer->doRun();
        controlEngine->doRun();
        process->doRun();
        std::this_thread::sleep_for(std::chrono::microseconds(1000));
    }

    process->doTerminate();
    info("Bye-Bye");

    return 0;
}

Running the CE

Chances are you want to try out our CE, right? Here’s how to do that.

First, get an OPC UA Client and connect to opc.tcp://127.0.0.1:4840. You should see our “ExamplePEA” in the address space.

Tip

If you are using UA Expert, you can use an UA template file examplePEA.uap. This automatically connects to the specified ip address and subscribes the relevant attributes to control our tank module.

Next, subscribe to these values:

@startsalt
{{T
 + ExamplePEA
 ++ Communications
 +++ Instances
 ++++ FillLevel
 +++++ V    **(fillLevel->V)**
 ++ Control
 +++ Services
 ++++ Service_0
 +++++ ReferencedServiceControl
 ++++++ CommandOp **(liquidService->CommandOp)**
 ++++++ StateCur   **(liquidService->StateCur)**
 ++++++ PrecedureOp   **(liquidService->PrecedureOp)**
 ++++++ PrecedureReq   **(liquidService->PrecedureReq)**
 ++++++ PrecedureCur   **(liquidService->PrecedureCur)**
 ++++++ ServiceOperationMode
 +++++++ StateOpAct **(liquidService->StateOpAct)**
 +++++++ StateOpOp  **(liquidService->StateOpOp)**
 +++++ mtp::Procedures
 ++++++ mtp::Procedure_0
 +++++++ ProcedureParameters
 ++++++++ ProcedureParameter_0
 +++++++++ ReferencedDataAssembly
 ++++++++++ Apply
 +++++++++++ ApplyEn  **(fillTank->ApplyEn)**
 +++++++++++ ApplyOp  **(fillTank->ApplyOp)**
 ++++++++++ VOp  **(fillTank->VOp)**
 ++++++++++ VOut **(fillTank->VOut)**
 ++++++++++ VReq **(fillTank->VOut)**
 +++++++ ProcessValueOuts
 ++++++++ ProcessValueOut_0
 +++++++++ ReferencedDataAssembly
 ++++++++++ V    **(inletValveState->V)**
 ++++++++ ProcessValueOut_1
 +++++++++ ReferencedDataAssembly
 ++++++++++ V    **(outletValveState->V)**
}}
@endsalt

The tank fill level should be 0%.

  1. Set liquidService->StateOpOp to true
    • liquidService->StateOpAct should become true

    • liquidService->StateCur should become 16 (IDLE)

By default the PEA is controlled internally, this command tells the PEA that the liquidService shall be controlled by us (the operator).

  1. Set liquidService->ProcedureOp to “1”
    • fillTank->ProcedureReq will become 1

This requests the procedure that should be executed. In our case we only have one that fills and drains the tank.

  1. Set fillTank->VOp to true
    • fillTank->VReq will be true

This is a parameter of our fill tank procedure. True indicates that we want to fill the tank instead of draining.

  1. Set fillTank->ApplyOp to true
    • fillTank->VOut will be become true as requested; the info-callback should be printed on the command line

This now applies the procedure parameter and it is ready to use for our procedure.

  1. Set liquidService->CommandOp to “4” (START)
    • fillTank->ApplyEn should become false

    • fillTank->VOut should become true

    • liquidService->StateCur should become 64 (EXECUTING)

    • The tank level should rise to 100% in ~20 seconds

This operation basically sends the command “START” to the PEA, which then executes the previously requested procedure with the set procedure parameters. In our case to fill the tank.

  1. The tank level will start to rise
    • when 100% are reached, liquidService->StateCur should become 131072 (COMPLETED)

  2. Set liquidService->CommandOp to “2” (RESET)
    • liquidService->StateCur should become 16 (IDLE) (its going through RESETTING, but its probably so fast you won’t see that)

After a procedure is completed it needs to be reset to go back in the IDLE state.

  1. Set fillTank->VOp to false
    • fillTank->VReq will be false

  2. Set fillTank->ApplyOp to true
    • fillTank->VOut will be become false as requested

  3. Set liquidService->CommandOp to “4” (START)
    • The tank level should decrease

Hit Ctrl+C to stop the example once you get bored.

Summary

In just over 500 lines of code, we created an entire MTP Control Engine. Our example is even likely to work in the real world with marginally more effort if we could open/close real valves with digital I/Os.

The complexity of a CE is largely hidden in the methodical and quite repetitious application of creating the ServiceHandler and ControlEngine. Also, the requirement to assign NodeIds in accordance with the MTP file tends to become tedious, but is easily automated.

Source Code

Here’s the complete source code from this example:

  1#include <string>
  2#include <iostream>
  3#include <chrono>
  4#include <thread>
  5
  6void log(const std::string& prefix, const std::string& msg)
  7{
  8    std::cout << prefix << " " << msg << std::endl;
  9    return;
 10}
 11
 12static void debug(const std::string& msg) { return log("DEBUG", msg); }
 13static void info(const std::string& msg)  { return log("INFO ", msg); }
 14static void error(const std::string& msg) { return log("ERROR", msg); }
 15
 16extern "C"
 17{
 18    #include <unistd.h>
 19    #include <signal.h>
 20}
 21
 22static bool run = true;
 23static void stopHandler([[maybe_unused]] int sign)
 24{
 25    run = false;
 26    return;
 27}
 28
 29class IProcessController
 30{
 31public:
 32    IProcessController() = default;
 33    virtual ~IProcessController() = default;
 34
 35    virtual bool isFaultPresent() = 0;
 36
 37    virtual void setInletValveOpen(const bool& open) = 0;
 38
 39    virtual void setOutletValveOpen(const bool& open) = 0;
 40
 41    virtual bool isInletValveOpen() const = 0;
 42
 43    virtual bool isOutletValveOpen() const = 0;
 44
 45    virtual float getFillLevelInPercent() const = 0;
 46};
 47
 48#include <chrono>
 49
 50#include <memory>
 51#include "tasking/BasicTaskLoopTask.hpp"
 52#include "locking/Lock.hpp"
 53namespace cena = semodia::controlengine::native;
 54using typename cena::osal::tasking::BasicTaskLoopTask;
 55
 56class ProcessController : public IProcessController, public BasicTaskLoopTask
 57{
 58private:
 59    const float tankCapacity;
 60
 61    float tankContents;
 62
 63    const float flowRate; // in liquid units/second
 64
 65    bool inletValveOpen;
 66    bool outletValveOpen;
 67
 68    std::chrono::microseconds lastUpdate;
 69
 70    long getMicroSecondsElapsedSinceLastUpdate()
 71    {
 72        return ((std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now().time_since_epoch()) - lastUpdate).count());
 73    }
 74
 75    void markLastUpdateNow()
 76    {
 77        lastUpdate = std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now().time_since_epoch());
 78    }
 79protected:
 80
 81    virtual void iterate() override
 82    {
 83        // Get the time perioid that passed since the last iteration
 84        const auto dt = this->getMicroSecondsElapsedSinceLastUpdate();
 85        const float seconds = static_cast<float>(dt) / 1000000.f;
 86        // Fill and/or drain. Yes. We can do both. We are THAT GOOD!
 87        if(inletValveOpen)
 88        {
 89            this->tankContents += (this->flowRate) * seconds;
 90        }
 91        if(outletValveOpen)
 92        {
 93            this->tankContents -= (this->flowRate) * seconds;
 94        }
 95
 96        // Prevent overfilling and underdraining:
 97        if(this->tankContents > this->tankCapacity)
 98        {
 99            this->tankContents = this->tankCapacity;
100        }
101        else if (this->tankContents < 0)
102        {
103            this->tankContents = 0;
104        }
105
106        // Update the timestamp
107        this->markLastUpdateNow();
108        return;
109    }
110
111public:
112    ProcessController(const float& tankCapacity, const float& flowRate)
113        :   IProcessController(),
114            BasicTaskLoopTask(std::make_unique<cena::osal::locking::Lock>()),
115            tankCapacity(tankCapacity < 0 ? 0 : tankCapacity),
116            tankContents(0),
117            flowRate(flowRate < 0 ? 0 : flowRate),
118            inletValveOpen(false),
119            outletValveOpen(false)
120    {
121        markLastUpdateNow();
122        return;
123    }
124
125    virtual ~ProcessController()
126    {
127        return;
128    }
129
130    ProcessController(const ProcessController&) = delete;
131    ProcessController& operator=(const ProcessController&) = delete;
132
133    bool isFaultPresent() override
134    {
135        return false;
136    }
137
138    virtual void setInletValveOpen(const bool &open) override
139    {
140        this->inletValveOpen = open;
141        return;
142    }
143
144    virtual void setOutletValveOpen(const bool &open) override
145    {
146        this->outletValveOpen = open;
147        return;
148    }
149
150    virtual bool isInletValveOpen() const override
151    {
152        return this->inletValveOpen;
153    }
154
155    virtual bool isOutletValveOpen() const override
156    {
157        return this->outletValveOpen;
158    }
159
160    virtual float getFillLevelInPercent() const override
161    {
162        return 100.f * (this->tankContents/this->tankCapacity);
163    }
164};
165
166#include "mtp/ModuleTypePackage.hpp"
167namespace mtp = cena::model::mtp;
168
169class IServiceHandler
170{
171public:
172    IServiceHandler() = default;
173    virtual ~IServiceHandler() = default;
174
175    virtual void update() = 0;
176
177    virtual void initializeMtpContents(mtp::ModuleTypePackage &mtp) = 0;
178};
179
180#include <memory>
181#include "mtp/BinView.hpp"
182#include "mtp/BinServParam.hpp"
183#include "mtp/Service.hpp"
184#include "mtp/ServiceSet.hpp"
185#include "mtp/ServiceControl.hpp"
186#include "mtp/Procedure.hpp"
187#include "mtp/ServiceSourceModeBaseFunction.hpp"
188#include "mtp/ServiceOperationModeBaseFunction.hpp"
189
190namespace mtp = semodia::controlengine::native::model::mtp;
191
192static const std::string mtpStaticContentNamespaceName("https://yourNamespace.com");
193
194class FillTankServiceHandler : public IServiceHandler
195{
196private:
197    std::shared_ptr<mtp::Service> liquidService = nullptr;
198
199protected:
200    std::shared_ptr<mtp::BinServParam> procedureModeFillTank;
201
202    std::shared_ptr<mtp::BinView> inletValveState;
203
204    std::shared_ptr<mtp::BinView> outletValveState;
205
206    std::shared_ptr<ProcessController> process;
207
208public:
209    FillTankServiceHandler(std::shared_ptr<ProcessController> process)
210        :   procedureModeFillTank(std::make_shared<mtp::BinServParam>("fillTank", "Instruction to fill or drain the tank", true, false, "True means fill", "False means drain")),
211            inletValveState(std::make_shared<mtp::BinView>("inletValveState", "Valve controlling liquid going into the tank", false, "True means open", "False means closed")),
212            outletValveState(std::make_shared<mtp::BinView>("outletValveState", "Valve controlling liquid going into the tank", false, "True means open", "False means closed")),
213            process(process)
214
215    {
216        return;
217    }
218    virtual ~FillTankServiceHandler()
219    {
220        return;
221    }
222
223    virtual void initializeMtpContents(mtp::ModuleTypePackage &mtp) override
224    {
225        this->inletValveState->getV()->setFixedOpcUaNodeId("s=InletValveState_V", mtpStaticContentNamespaceName);
226        this->inletValveState->getWQC()->setFixedOpcUaNodeId("s=InletValveState_WQC", mtpStaticContentNamespaceName);
227        this->outletValveState->getV()->setFixedOpcUaNodeId("s=OutletValveState_V", mtpStaticContentNamespaceName);
228        this->outletValveState->getWQC()->setFixedOpcUaNodeId("s=OutletValveState_WQC", mtpStaticContentNamespaceName);
229        this->procedureModeFillTank->getVInt()->setFixedOpcUaNodeId("s=ProcedureModeFillTank_VInt", mtpStaticContentNamespaceName);
230        this->procedureModeFillTank->getVExt()->setFixedOpcUaNodeId("s=ProcedureModeFillTank_VExt", mtpStaticContentNamespaceName);
231        this->procedureModeFillTank->getVOp()->setFixedOpcUaNodeId("s=ProcedureModeFillTank_VOp", mtpStaticContentNamespaceName);
232        this->procedureModeFillTank->getVOut()->setFixedOpcUaNodeId("s=ProcedureModeFillTank_VOut", mtpStaticContentNamespaceName);
233        this->procedureModeFillTank->getVReq()->setFixedOpcUaNodeId("s=ProcedureModeFillTank_VReq", mtpStaticContentNamespaceName);
234        this->procedureModeFillTank->getApply()->getApplyEn()->setFixedOpcUaNodeId("s=ProcedureModeFillTank_ApplyEn", mtpStaticContentNamespaceName);
235        this->procedureModeFillTank->getApply()->getApplyOp()->setFixedOpcUaNodeId("s=ProcedureModeFillTank_ApplyOp", mtpStaticContentNamespaceName);
236        this->procedureModeFillTank->getApply()->getApplyExt()->setFixedOpcUaNodeId("s=ProcedureModeFillTank_ApplyExt", mtpStaticContentNamespaceName);
237        this->procedureModeFillTank->getApply()->getApplyInt()->setFixedOpcUaNodeId("s=ProcedureModeFillTank_ApplyIn", mtpStaticContentNamespaceName);
238
239        mtp.getControl()->createService("FillTankService");
240        if( mtp::MtpStatusCode::GOOD == mtp.getControl()->getServiceByName("FillTankService", liquidService))
241        {
242            debug("Created FillTankService");
243
244            std::uint32_t procedureIdAssignedByService = 0;
245            mtp::MtpStatusCode retval = liquidService->createProcedure("FillTank", true,  procedureIdAssignedByService,
246                 [&](mtp::ProcedureHealthView& commandInfo)
247                {
248                    commandInfo.setCommandEnable(mtp::ServiceCommandId::COMPLETE, true);
249                    commandInfo.setCommandEnable(mtp::ServiceCommandId::ABORT, true);
250                    commandInfo.setCommandEnable(mtp::ServiceCommandId::HOLD, true);
251                    commandInfo.setCommandEnable(mtp::ServiceCommandId::STOP, true);
252                    commandInfo.setCommandEnable(mtp::ServiceCommandId::RESET, true);
253                    commandInfo.setCommandEnable(mtp::ServiceCommandId::START, !(this->process->isFaultPresent()));
254                    commandInfo.setCommandEnable(mtp::ServiceCommandId::HOLD, this->process->isFaultPresent());
255                    commandInfo.setCommandEnable(mtp::ServiceCommandId::UNHOLD, !(this->process->isFaultPresent()));
256
257                    commandInfo.setCommandEnable(mtp::ServiceCommandId::RESTART, false);
258                    commandInfo.setCommandEnable(mtp::ServiceCommandId::PAUSE, false);
259                    return;
260                }
261            );
262
263            if(1 != procedureIdAssignedByService)
264            {
265                error("We expected ProcedureId 1... but the CENA has assigned another ID");
266            }
267
268            // Procedure fillTank (intended to be selectable using MTP procedure ID 1 and to be self-completing)
269            std::shared_ptr<mtp::Procedure> liquidProcedure = nullptr; // We'll need this
270            if(mtp::MtpStatusCode::GOOD == retval && mtp::MtpStatusCode::GOOD == liquidService->getProcedureByName("FillTank", liquidProcedure))
271            {
272                // Add the `ProcedureParameters` (which work exactly like `ConfigurationParameters`)
273                liquidProcedure->createProcedureParameter("FillMode", this->procedureModeFillTank);
274
275                // add ReportValues
276                // ... if we had any, which we don't
277
278                // ProcessValueOut
279                liquidProcedure->createProcessValueOut(this->inletValveState);
280                liquidProcedure->createProcessValueOut(this->outletValveState);
281
282                // add ProcessValueIns
283                // ... if we had any, which we don't
284            }
285            else
286            {
287                error("MTP Initialization: Creating procedure 'FillTank' failed");
288            }
289
290            // << If we had more procedures, they would go here
291
292            liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateChannel()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateChannel", mtpStaticContentNamespaceName);
293            liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateAutAct()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateAutAct", mtpStaticContentNamespaceName);
294            liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateOpAct()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateOpAct", mtpStaticContentNamespaceName);
295            liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateOffAct()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateOffAct", mtpStaticContentNamespaceName);
296            liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateOpOp()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateOpOp", mtpStaticContentNamespaceName);
297            liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateAutOp()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateAutOp", mtpStaticContentNamespaceName);
298            liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateOffOp()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateOffOp", mtpStaticContentNamespaceName);
299            liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateOpAut()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateOpAut", mtpStaticContentNamespaceName);
300            liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateAutAut()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateAutAut", mtpStaticContentNamespaceName);
301            liquidService->getReferencedServiceControl()->getServiceOperationMode()->getStateOffAut()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateOffAut", mtpStaticContentNamespaceName);
302            liquidService->getReferencedServiceControl()->getCommandEn()->setFixedOpcUaNodeId("s=FillTankServiceControl_CommandEn", mtpStaticContentNamespaceName);
303            liquidService->getReferencedServiceControl()->getCommandOp()->setFixedOpcUaNodeId("s=FillTankServiceControl_CommandOp", mtpStaticContentNamespaceName);
304            liquidService->getReferencedServiceControl()->getCommandInt()->setFixedOpcUaNodeId("s=FillTankServiceControl_CommandInt", mtpStaticContentNamespaceName);
305            liquidService->getReferencedServiceControl()->getCommandExt()->setFixedOpcUaNodeId("s=FillTankServiceControl_CommandExt", mtpStaticContentNamespaceName);
306            liquidService->getReferencedServiceControl()->getProcedureCur()->setFixedOpcUaNodeId("s=FillTankServiceControl_ProcedureCur", mtpStaticContentNamespaceName);
307            liquidService->getReferencedServiceControl()->getProcedureOp()->setFixedOpcUaNodeId("s=FillTankServiceControl_ProcedureOp", mtpStaticContentNamespaceName);
308            liquidService->getReferencedServiceControl()->getProcedureInt()->setFixedOpcUaNodeId("s=FillTankServiceControl_ProcedureInt", mtpStaticContentNamespaceName);
309            liquidService->getReferencedServiceControl()->getProcedureExt()->setFixedOpcUaNodeId("s=FillTankServiceControl_ProcedureExt", mtpStaticContentNamespaceName);
310            liquidService->getReferencedServiceControl()->getStateCur()->setFixedOpcUaNodeId("s=FillTankServiceControl_StateCur", mtpStaticContentNamespaceName);
311            liquidService->getReferencedServiceControl()->getProcedureReq()->setFixedOpcUaNodeId("s=FillTankServiceControl_ProcedureReq", mtpStaticContentNamespaceName);
312            liquidService->getReferencedServiceControl()->getProcParamApply()->getApplyOp()->setFixedOpcUaNodeId("s=FillTankServiceControl_ProcParamApplyOp", mtpStaticContentNamespaceName);
313            liquidService->getReferencedServiceControl()->getProcParamApply()->getApplyEn()->setFixedOpcUaNodeId("s=FillTankServiceControl_ProcParamApplyEn", mtpStaticContentNamespaceName);
314            liquidService->getReferencedServiceControl()->getProcParamApply()->getApplyInt()->setFixedOpcUaNodeId("s=FillTankServiceControl_ProcParamApplyInt", mtpStaticContentNamespaceName);
315            liquidService->getReferencedServiceControl()->getProcParamApply()->getApplyExt()->setFixedOpcUaNodeId("s=FillTankServiceControl_ProcParamApplyExt", mtpStaticContentNamespaceName);
316            liquidService->getReferencedServiceControl()->getConfigParamApply()->getApplyOp()->setFixedOpcUaNodeId("s=FillTankServiceControl_ConfigParamApplyOp", mtpStaticContentNamespaceName);
317            liquidService->getReferencedServiceControl()->getConfigParamApply()->getApplyEn()->setFixedOpcUaNodeId("s=FillTankServiceControl_ConfigParamApplyEn", mtpStaticContentNamespaceName);
318            liquidService->getReferencedServiceControl()->getConfigParamApply()->getApplyInt()->setFixedOpcUaNodeId("s=FillTankServiceControl_ConfigParamApplyInt", mtpStaticContentNamespaceName);
319            liquidService->getReferencedServiceControl()->getConfigParamApply()->getApplyExt()->setFixedOpcUaNodeId("s=FillTankServiceControl_ConfigParamApplyExt", mtpStaticContentNamespaceName);
320            liquidService->getReferencedServiceControl()->getWQC()->setFixedOpcUaNodeId("s=FillTankServiceControl_WQC", mtpStaticContentNamespaceName);
321            liquidService->getReferencedServiceControl()->getOSLevel()->setFixedOpcUaNodeId("s=FillTankServiceControl_OSLevel", mtpStaticContentNamespaceName);
322            liquidService->getReferencedServiceControl()->getPosTextID()->setFixedOpcUaNodeId("s=FillTankServiceControl_PosTextID", mtpStaticContentNamespaceName);
323            liquidService->getReferencedServiceControl()->getInteractQuestionID()->setFixedOpcUaNodeId("s=FillTankServiceControl_InteractQuestionID", mtpStaticContentNamespaceName);
324            liquidService->getReferencedServiceControl()->getInteractAnswerID()->setFixedOpcUaNodeId("s=FillTankServiceControl_InteractAnswerID", mtpStaticContentNamespaceName);
325            liquidService->getReferencedServiceControl()->getInteractAddInfo()->setFixedOpcUaNodeId("s=FillTankServiceControl_InteractAddInfo", mtpStaticContentNamespaceName);
326            liquidService->getReferencedServiceControl()->getReportValueFreeze()->setFixedOpcUaNodeId("s=FillTankServiceControl_ReportValueFreeze", mtpStaticContentNamespaceName);
327            liquidService->getReferencedServiceControl()->getServiceSourceMode()->getSrcChannel()->setFixedOpcUaNodeId("s=FillTankServiceControl_SrcChannel", mtpStaticContentNamespaceName);
328            liquidService->getReferencedServiceControl()->getServiceSourceMode()->getSrcExtAut()->setFixedOpcUaNodeId("s=FillTankServiceControl_SrcExtAut", mtpStaticContentNamespaceName);
329            liquidService->getReferencedServiceControl()->getServiceSourceMode()->getSrcIntAut()->setFixedOpcUaNodeId("s=FillTankServiceControl_SrcIntAut", mtpStaticContentNamespaceName);
330            liquidService->getReferencedServiceControl()->getServiceSourceMode()->getSrcIntOp()->setFixedOpcUaNodeId("s=FillTankServiceControl_SrcIntOp", mtpStaticContentNamespaceName);
331            liquidService->getReferencedServiceControl()->getServiceSourceMode()->getSrcExtOp()->setFixedOpcUaNodeId("s=FillTankServiceControl_SrcExtOp", mtpStaticContentNamespaceName);
332            liquidService->getReferencedServiceControl()->getServiceSourceMode()->getSrcIntAct()->setFixedOpcUaNodeId("s=FillTankServiceControl_SrcIntAct", mtpStaticContentNamespaceName);
333            liquidService->getReferencedServiceControl()->getServiceSourceMode()->getSrcExtAct()->setFixedOpcUaNodeId("s=FillTankServiceControl_SrcExtAct", mtpStaticContentNamespaceName);
334
335            liquidProcedure->getReferencedProcedureHealthView()->getWQC()->setFixedOpcUaNodeId("s=FillTankHV_WQC", mtpStaticContentNamespaceName);
336            liquidProcedure->getReferencedProcedureHealthView()->getCommandInfo()->setFixedOpcUaNodeId("s=FillTankHV_CommandInfo", mtpStaticContentNamespaceName);
337        }
338        else
339        {
340            error("Failed to create FillTankService");
341        }
342
343        std::shared_ptr<mtp::Procedure> liquidProcedure = nullptr; // We'll need this
344        if(mtp::MtpStatusCode::GOOD == liquidService->getProcedureByName("FillTank", liquidProcedure))
345        {
346            liquidProcedure->attachUserCallback(mtp::ServiceStateId::STARTING, std::bind(&FillTankServiceHandler::starting, this, std::placeholders::_1));
347            liquidProcedure->attachUserCallback(mtp::ServiceStateId::RESUMING, std::bind(&FillTankServiceHandler::starting, this, std::placeholders::_1));
348            liquidProcedure->attachUserCallback(mtp::ServiceStateId::UNHOLDING, std::bind(&FillTankServiceHandler::starting, this, std::placeholders::_1));
349            liquidProcedure->attachUserCallback(mtp::ServiceStateId::EXECUTE, std::bind(&FillTankServiceHandler::executing, this, std::placeholders::_1));
350            liquidProcedure->attachUserCallback(mtp::ServiceStateId::COMPLETING, std::bind(&FillTankServiceHandler::completing, this, std::placeholders::_1));
351            liquidProcedure->attachUserCallback(mtp::ServiceStateId::PAUSING, std::bind(&FillTankServiceHandler::completing, this, std::placeholders::_1));
352            liquidProcedure->attachUserCallback(mtp::ServiceStateId::HOLDING, std::bind(&FillTankServiceHandler::holding, this, std::placeholders::_1));
353            liquidProcedure->attachUserCallback(mtp::ServiceStateId::STOPPING, std::bind(&FillTankServiceHandler::completing, this, std::placeholders::_1));
354            liquidProcedure->attachUserCallback(mtp::ServiceStateId::ABORTING, std::bind(&FillTankServiceHandler::completing, this, std::placeholders::_1));
355
356            this->procedureModeFillTank->allowValueUpdates(false);
357
358            std::shared_ptr<mtp::ProcedureParameter> procedureParameter;
359            if(mtp::MtpStatusCode::GOOD == liquidProcedure->getProcedureParameterByName("FillMode", procedureParameter))
360            {
361                procedureParameter->setStateBasedUpdateEnable(mtp::ServiceStateId::IDLE, mtp::StateBasedUpdateEnableSetting::EnabledByState);
362                procedureParameter->setStateBasedUpdateEnable(mtp::ServiceStateId::STARTING, mtp::StateBasedUpdateEnableSetting::DisabledByState);
363            }
364
365            auto procedureModeFillTankReference = this->procedureModeFillTank;
366            this->procedureModeFillTank->afterApplyDo([procedureModeFillTankReference](){
367                info("The mode of filling the tank was changed to `" + std::to_string((int) (procedureModeFillTankReference->getCurrentSetpoint())) + "`");
368                return;
369            });
370        }
371
372        if(liquidService != nullptr)
373        {
374            liquidService->requestModeControl(false);
375        }
376
377        this->procedureModeFillTank->requestModeControl(false);
378        this->procedureModeFillTank->setSync(true);
379
380        return;
381    }
382
383    virtual void update() override
384    {
385        {
386            // Note how [Bin/DInt/Ana/String]View-Types can update their VOut by assigning the appropriate type.
387            // For ParameterElements, this would affect VFbk.
388            *this->inletValveState = this->process->isInletValveOpen();
389            this->inletValveState->setQualityCode(mtp::QualityCode::OK);
390
391            *this->outletValveState = this->process->isOutletValveOpen();
392            this->outletValveState->setQualityCode(mtp::QualityCode::OK);
393
394            // << We don't have any ProcessValueIns to write to the process, but if we had some, they would go here
395        }
396
397        return;
398    }
399
400    void starting(bool& completed)
401    {
402        debug("FillTankServiceHandler: LiquidProcedure: Starting");
403        if (this->procedureModeFillTank->getCurrentSetpoint() == true)
404        {
405            // "True" is "fill"
406            debug("Commencing fill");
407            this->process->setInletValveOpen(true);
408            this->process->setOutletValveOpen(false);
409        }
410        else
411        {
412            // "False" is "drain"
413            debug("Commencing drain");
414            this->process->setInletValveOpen(false);
415            this->process->setOutletValveOpen(true);
416        }
417
418        completed = true;
419        return;
420    }
421
422    void executing(bool& completed)
423    {
424        debug("FillTankServiceHandler: LiquidProcedure: Executing");
425        completed = false;
426        if(process->isFaultPresent())
427        {
428            // Declare PEA as service controller and take the service to Automatic/Internal, then issue HOLD
429            if(this->liquidService != nullptr)
430            {
431                this->liquidService->requestModeControl(true);
432                this->liquidService->requestOperationMode(mtp::OperationModeId::Automatic, mtp::ServiceSourceModeId::Internal);
433                this->liquidService->requestCommand(mtp::ServiceCommandId::HOLD);
434            }
435            return;
436        }
437
438        const auto fillLevel = this->process->getFillLevelInPercent();
439        debug("FillLevel: " + std::to_string(fillLevel) + " %");
440        if (true == this->procedureModeFillTank->getCurrentSetpoint())
441        {
442            // "True" is "fill"
443            completed = (fillLevel >= 100);
444        }
445        else
446        {
447            // "False" is "drain"
448            completed = fillLevel <= 0;
449        }
450
451
452        return;
453    }
454
455    void completing(bool& completed)
456    {
457        debug("FillTankServiceHandler: LiquidProcedure: Completing");
458        this->process->setInletValveOpen(false);
459        this->process->setOutletValveOpen(false);
460
461        completed = true;
462        return;
463    }
464
465    void holding(bool& completed)
466    {
467        this->completing(completed);
468        if (completed)
469        {
470            // Remember how EXECUTING had to wrestle away control from the POL? This is where we give it back
471            // so that the POL can leave HELD once the error is cleared.
472            if (this->liquidService != nullptr)
473            {
474                this->liquidService->requestModeControl(false);
475            }
476        }
477        return;
478    }
479};
480
481#include "controlengine/MtpControlEngine.hpp"
482#include "timing/BasicTimer.hpp"
483#include "opcua/open62541/OpcUaServerOpen62541.hpp"
484#include "mtp/AnaView.hpp"
485#include "mtp/PeaInformationLabel.hpp"
486
487using typename cena::reflection::opcua::OpcUaServerOpen62541;
488
489class ControlEngine : public mtp::MtpControlEngine
490{
491private:
492    std::shared_ptr<OpcUaServerOpen62541> opcuaServer; // Co-Owned by base class and reflection
493
494    std::shared_ptr<ProcessController> process;
495
496    FillTankServiceHandler liquidService;
497
498    std::shared_ptr<mtp::AnaView> fillLevel;
499
500protected:
501
502    void start() override
503    {
504        // This order is **very important**:
505        // Call the server's start() first, because the control engine's start will create the mtp and reflect it!
506        // So the server always needs to be started/initialized first!
507        this->opcuaServer->doStart();
508
509        this->MtpControlEngine::start();
510    }
511
512    void iterate() override
513    {
514        // The first update triggers the CE (and any service handler) to update their DataAssemblies from/to the
515        // process handler.
516        this->update();
517        this->MtpControlEngine::iterate();
518    }
519
520    void stop() override
521    {
522        this->MtpControlEngine::stop();
523        this->opcuaServer->doTerminate();
524    }
525public:
526    ControlEngine(std::shared_ptr<OpcUaServerOpen62541> opcuaServer, std::shared_ptr<ProcessController> process)
527        :   mtp::MtpControlEngine( std::make_unique<cena::osal::locking::Lock>(), std::make_unique<cena::osal::timing::BasicTimer>(),
528                                   opcuaServer,
529                                   100, // Update only every 100ms
530                                   "ExamplePEA",
531                                   "BeverageProduction",
532                                   "https://semodia.com/aas/ExamplePEA/manual.pdf",
533                                   "123",
534                                   "Semodia GmbH",
535                                   "https://semodia.com",
536                                   "Product_C0DE",
537                                   "https://semodia.com/aas/ExamplePEA/666",
538                                   "666",
539                                   "3.0.0",
540                                   "Semodia GmbH",
541                                   "https://semodia.com",
542                                   "http://localhost:5000"),
543            opcuaServer(opcuaServer),
544            process(process),
545            liquidService(process),
546            fillLevel(std::make_shared<mtp::AnaView>("FillLevel", "The Tanks current fill level", 0, 0, 100, mtp::UnitCode::PERCENT))
547    {
548        return;
549    };
550
551    virtual ~ControlEngine()
552    {
553        return;
554    };
555
556    ControlEngine(const ControlEngine &other) = delete;
557    ControlEngine &operator=(const ControlEngine &rhs) = delete;
558
559    virtual void initializeMtpContents() override
560    {
561        // Our AnaView might need some static nodeIds
562        this->fillLevel->getV()->setFixedOpcUaNodeId("s=fillLevel_V", mtpStaticContentNamespaceName);
563        this->mtp->addDataAssembly(this->fillLevel);
564
565        // Some parts of the PeaInformationLabel have to be available at runtime
566
567        this->mtp->getPeaInformation()->getAssetId()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.AssetId", mtpStaticContentNamespaceName);
568        this->mtp->getPeaInformation()->getComponentName()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.ComponentName", mtpStaticContentNamespaceName);
569        this->mtp->getPeaInformation()->getDeviceClass()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.DeviceClass", mtpStaticContentNamespaceName);
570        this->mtp->getPeaInformation()->getDeviceHealth()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.DeviceHealth", mtpStaticContentNamespaceName);
571        this->mtp->getPeaInformation()->getDeviceManual()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.DeviceManual", mtpStaticContentNamespaceName);
572        this->mtp->getPeaInformation()->getDeviceRevision()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.DeviceRevision", mtpStaticContentNamespaceName);
573        this->mtp->getPeaInformation()->getHardwareRevision()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.HardwareRevision", mtpStaticContentNamespaceName);
574        this->mtp->getPeaInformation()->getManufacturer()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.Manufacturer", mtpStaticContentNamespaceName);
575        this->mtp->getPeaInformation()->getManufacturerUri()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.ManufacturerUri", mtpStaticContentNamespaceName);
576        this->mtp->getPeaInformation()->getModel()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.Model", mtpStaticContentNamespaceName);
577        this->mtp->getPeaInformation()->getProductCode()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.ProductCode", mtpStaticContentNamespaceName);
578        this->mtp->getPeaInformation()->getProductInstanceUri()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.ProductInstanceUri", mtpStaticContentNamespaceName);
579        this->mtp->getPeaInformation()->getRevisionCounter()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.RevisionCounter", mtpStaticContentNamespaceName);
580        this->mtp->getPeaInformation()->getSerialNumber()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.SerialNumber", mtpStaticContentNamespaceName);
581        this->mtp->getPeaInformation()->getSoftwareRevision()->setFixedOpcUaNodeId("s=example_mtp_controlengine_PEA.SoftwareRevision", mtpStaticContentNamespaceName);
582
583
584
585        // Invoke each of our service handlers
586        this->liquidService.initializeMtpContents(*this->mtp);
587        debug("Control Engine's MTP is initialized");
588        return;
589    }
590
591    virtual void update()
592    {
593        // Update our data assemblies:
594        *this->fillLevel = this->process->getFillLevelInPercent();
595        if(*this->fillLevel > 100 || *this->fillLevel < 0)
596        {
597            this->fillLevel->setQualityCode(mtp::QualityCode::OUT_OF_SPECIFICATION);
598        }
599        else
600        {
601            this->fillLevel->setQualityCode(mtp::QualityCode::OK);
602        }
603
604        // Update each of our services:
605        this->liquidService.update();
606    };
607};
608
609int main([[maybe_unused]] int argc, [[maybe_unused]] char** argv)
610{
611    signal(SIGINT, stopHandler);
612    signal(SIGTERM, stopHandler);
613
614    info("Welcome to our ControlEngine Example");
615
616    auto process = std::make_shared<ProcessController>(10, 0.4); // ~ 20s till full or empty
617    process->doStart();
618
619    auto uaServer = std::make_shared<OpcUaServerOpen62541>(std::make_unique<cena::osal::locking::Lock>(), std::make_unique<cena::osal::locking::Lock>());
620    auto controlEngine = std::make_shared<ControlEngine>(uaServer, process);
621    controlEngine->doStart();
622
623    info("Running the control engine");
624    while(run)
625    {
626        uaServer->doRun();
627        controlEngine->doRun();
628        process->doRun();
629        std::this_thread::sleep_for(std::chrono::microseconds(1000));
630    }
631
632    process->doTerminate();
633    info("Bye-Bye");
634
635    return 0;
636}