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:
Let’s review the main components before we move on:
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.
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.
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.
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: |
|
||
Process Input Values: |
|||
Process Output Values: |
|
||
Report Values: |
|||
EXECUTE self-completing: |
yes |
||
State |
Transition |
||
IDLE |
|||
START |
no fault indicated |
||
STARTING |
sc |
|
|
EXECUTING |
|
||
COMPLETE |
n/a |
||
COMPLETING |
sc |
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
How to create services with procedure callbacks
How to insert Procedure or Service parameters
How to exert control over your process
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:
- For each Service:
Optional: Set up ApplyEn for each ConfigurationParameter
Optional: Set up ApplyCallbacks for each ConfigurationParameter
- For each Procedure:
We need to attach our procedure callbacks
Optional: Set up ApplyEn for each ProcedureParameter
Optional: Set up ApplyCallbacks for each ProcedureParameter
We need to set the default controller (who may change offline/automatic/operator modes) of Services
We need to set the default controller (who may change offline/automatic/operator modes) of
service configuration parameters
procedure parameters
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.
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.
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.
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:
Updating our parameters either by reading or writing from/to the process
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:
Read all outputs from the process (ReportValues, ProcessValueOuts, potential Fbk in Service/ProcedureParameters)
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:
System Setup (I/O configuration, logging, creating our classes)
The main loop
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:
The tank fill level should be 0%.
- 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).
- 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.
- 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.
- 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.
- 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.
- The tank level will start to rise
when 100% are reached, liquidService->StateCur should become 131072 (COMPLETED)
- 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.
- Set fillTank->VOp to false
fillTank->VReq will be false
- Set fillTank->ApplyOp to true
fillTank->VOut will be become false as requested
- 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}