libuniset2 - library for creating ACS. Better to see once ... Part 1

Published on March 04, 2016

libuniset2 - library for creating ACS. Better to see once ... Part 1

    Once upon a time ... I wrote an article “Acquaintance with libuniset - a library for creating ACS” , there were plans to write a continuation, but it did not work out. Since then, the library has grown significantly and version 2.0 has already been released, in which there are many new features: remote viewing of logs and program variables, support for various useful and not-so protocols and databases, there is even a “time-machine”, but if this comes to this ...

    In general, I gathered my strength and decided that it was better to "see it all" with a specific example.

    Therefore, anyone else interested, please.

    Several articles are planned on the stages of work:

    Unfortunately, it is difficult to create an example of a real multi-node network project (at least you need a network of virtual machines, package installation, deployment, etc.). Therefore, I will consider a simple example that can be compiled and run locally and show the operation of the basic uniset concepts on it.

    As an example, let's take the following “spherical” task:
    It is necessary to write the process of level control in the “tank”.

    The process fills the tank (turns on the pump), and as soon as the level reaches the set upper threshold (95%), it sends a command to the pump, which “empties” the tank . As soon as the level reaches the lower threshold (5%), it begins to fill again. And so in a circle.

    Work should begin after the arrival of (1) the special command “Start Work” and be completed when the command “Start Work” is removed (0).

    Because Since I myself mainly work with ALTLinux , all examples will be considered under this OS.
    I am sitting on Sisyphus , but examples will work on the P7 Platform
    So ...

    Preparatory step - creating a project, installing packages

    Install the necessary packages for work in the system
    apt-get install libuniset2-devel libuniset2-extensions-devel libuniset2-utils libomniORB-names git gitk git-gui etersoft-build-utils ccache

    Now you can build our project ...

    Important: libuniset2 requires a compiler with C ++ 11 support.

    In order not to suffer too much, I prepared the “foundation”, just download the master branch.

    So, we have the following directory structure (of course, it can be any, this structure is just a “habit”)

    / Utilities - various auxiliary utilities (scripts) that the project usually grows in
    / conf - project configuration files
    / docs - documentation (but what without it in good project)
    / include - a place for the general project header files
    / src - the actual sources
    / lib - a place for the common project library (code for common functions) - the actual file for building the project (yes, I use autotools) - an auxiliary script to generate

    and other files, which of course should be in a "beautiful" project.

    We launch && configure && make

    By the way, I'm used to using jmake - it's such a wrapper over make from the etersoft-build-utils package , taking into account the presence of ccache in the system and the number of processors.

    If everything compiles, then you can go further ... In general, we still have nothing.

    Step One - Configure a Uniset Project

    In uniset projects, the entire system configuration is stored (usually in one) xml file. Again, out of habit, it is called configure.xml and is placed in "conf /". I won’t write down the format of this file, the goal is “to start something and see the work as soon as possible” , but there is a description in the documentation ...

    To start filling the configuration file, we need to understand what “sensors” we need in the description of the task our project. In general, we get the following list:
    • START WORK command - DI sensor
    • current tank level - AI sensor
    • command to turn on the "filling pump" - DO sensor
    • command to turn on the "emptying pump" - DO sensor

    All this is entered in the sensors section (we assign what id we want, if only unique).
    The result will be something like this:
    <sensors name="Sensors" ...>
            <item id="100" name="OnControl_S" iotype="DI" textname="Управление работой (1 - работать, 0 - не работать)"/>
    	<item id="101" name="Level_AS" iotype="AI" textname="Текущий уровень в цистерне"/>
    	<item id="102" name="CmdLoad_C" iotype="DO" textname="Команда на включение насоса 'наполнения'"/>
    	<item id="103" name="CmdUnload_C" iotype="DO" textname="Команда на включение насоса 'опустошения'"/>

    You can see that when introducing sensors, we set each one a unique identifier ( id ), a unique name ( name ) and there is still textname = ".." - this text can be used subsequently for GUI or other applications.
    Each sensor has one of four types ( iotype ). Here is a link to the documentation.
    • DI - Digital Input
    • DO - Digital Output
    • AI - Analog Input
    • AO - Analog Output

    In general, the division into such types is a little conditional, while there is no work with real input / output. But the types themselves determine what a particular sensor is from the point of view of the system being designed. “Entries” is what “enters the system”, “exits” is what “exits” (that is, what the system forms).

    With the sensors previously figured out ...

    Step Two - Create a Simulator

    In fact, in order for us to debug the work of our management process, we need to write a little more simulator. Essentially the same management process, only imitating the “necessary equipment”. Therefore, we start with it, as a simpler process ( with a stretch we will assume that we are fans of TDD ).
    As a result, we have two more

    src / Algorithms / Controller directories in the project — the control process (which solves the problem)
    src / Algorithms / Imitator — the simulator for adjusting the control process.

    Since there are a couple more service directories (which will be discussed later), then I select processes in a separate subdirectory "Algorithms" .

    So the task of the simulator (the simplest "dumb" option):
    • By the command “fill”, simulate filling the tank (increasing the value of the analog level sensor in the tank).
    • On the command "empty" simulate emptying the tank (decreasing the value of the analog level sensor in the tank).

    Creating an xml file describing the simulator

    It's time to talk about one helper, but very important uniset utility: uniset2-codegen .
    Using it, on the basis of a special xml description, a “skeleton” of a process is generated that contains the entire “routine part” of the work. After that, “inheriting” from the generated class (skeleton), it is enough just to implement the necessary functionality (redefining virtual functions).

    The xml-description file is a simple xml-file which describes the "inputs" and "outputs"
    for this process (if we consider it a "black box"). It is important to understand that:
    • “Inputs” and “outputs” are essentially sensors that your process “receives” (inputs) or “sets” (outputs)
    • what is the "input" for one process, it may well be the "output" for another.
    • inputs / outputs are not the sensors themselves, but simply class fields, which later (at the start) are attached to specific sensors.

    To realize all this, let's get right to the point. For the simulator, we have:
    two inputs - these are the fill / empty commands and one output - this is the actually simulated level in the tank. Then its part of the xml file describing the inputs / outputs will look like this:

          <item name="Level_s" vartype="out" iotype="AI" comment="Текущий уровень в цистерне">
          <item name="cmdLoad_c" vartype="in" iotype="DO" comment="Команда начать заполнение">
          <item name="cmdUload_c" vartype="in" iotype="DO" comment="Команда начать «опустошение»">

    name - sets the name of the "variable" (in the skeleton of the class); class fields containing (the id of the sensor to which this input / output is attached), as well as a variable containing the current value, will be generated from this name.
    vartype - Defines the type of the variable "input" or "output". Input is what is “read”, “exit” is what is “written”.
    comment - turns into a doxygen -style comment (/ *! <xxxxx * /)

    Accordingly, in general, we can subsequently launch several simulators, each of which will be tied to its sensors ...

    For those who are interested in more details ..
    The full description file looks like this:
    <?xml version="1.0" encoding="utf-8"?>
    	name 		- название класса
    	msgcount	- сколько сообщений обрабатывается за один раз
    	sleep_msec	- пауза между итерациями в работе процесса
    		in 	- входы (только для чтения)
    		out	- выходы (запись)
    	<set name="class-name" val="Imitator"/>
    	<set name="msg-count" val="30"/>
    	<set name="sleep-msec" val="150"/>
    		<item name="stepVal" type="long" const="1" default="6" comment="Шаг наполнения (скорость)"/>
    		<item name="stepTime" type="long" const="1" default="500" comment="Время на один шаг, мсек"/>
    		<item name="Level_s" vartype="out" iotype="AI" comment="Текущий уровень в цистерне"/>
    		<item name="cmdLoad_c" vartype="in" iotype="DO" comment="Команда начать заполнение"/>
    		<item name="cmdUload_c" vartype="in" iotype="DO" comment="Команда начать 'опусташение'"/>

    Writing a simulator code

    Having formed the inputs / outputs, we can now generate a skeleton. Without going into details, this is done by the command:
    uniset2-codegen -n Imitator --ask --no-main imitator.src.xml

    parameter --ask - says to generate the process based on change notifications (ordering sensors)
    parameter --no-main - says not to generate since we will write your own.
    the -n option is the name of the class for which the skeleton is generated.
    In general, this command is added to
    bin_PROGRAMS = imitator
    BUILT_SOURCES = Imitator_SK.h Imitator_SK.h
    imitator_LDADD = $(top_builddir)/lib/
    #imitator_CPPFLAGS = 
    imitator_SOURCES =
    Imitator_SK.h imitator.src.xml
    	@UNISET_CODEGEN@ -n Imitator --topdir $(top_builddir)/ --ask --no-main imitator.src.xml
    	rm -rf * *_SK.h *.log

    As a result, two files will be generated:
    Imitator_SK.h - header - implementation

    These are very interesting files, to study what is done there, but for now we don’t care about them ...
    So we are laying our actual implementation. We create two files in which our logic of work will be implemented.

    Let's look at Imitator.h in more detail.
    #ifndef Imitator_H_
    #define Imitator_H_
    // -----------------------------------------------------------------------------
    #include <string>
    #include "Imitator_SK.h"
    // -----------------------------------------------------------------------------
        \page_Imitator Процесс имитирующий работу насоса (наполнение и опусташение цистерны)
        - \ref sec_imitator_Common
    	\section sec_loadproc_Common Описание алгоритма имитатора
    		По команде "наполнить"(cmdLoad_c) процесс начинает наполнять цистерну (увеличивать уровень).
    		По команде "опусташить"(cmdUnload_c) процесс начинает опустошать цистерну (уменьшать уровень).
    class Imitator:
    	public Imitator_SK
    		Imitator( UniSetTypes::ObjectId id, xmlNode* cnode, const std::string& prefix = "" );
    		virtual ~Imitator();
    		enum Timers
    		virtual void sensorInfo( const UniSetTypes::SensorMessage* sm ) override;
    		virtual void timerInfo( const UniSetTypes::TimerMessage* tm ) override;
    // -----------------------------------------------------------------------------
    #endif // Imitator_H_

    In a uniset system, every object that wants to receive notifications about sensors and generally interact with the outside world somehow must have a unique identifier within the system. In addition to this, our process needs to connect the inputs / outputs with specific sensors, for this, in configure.xml, each process has its own configuration section.
    As a result ... in the constructor we pass the identifier of the object, as well as a pointer to a specific xml-node with the settings for this process. In addition, there is prefix (this is for processing command line arguments, it will be shown later how to use it).

    Based on the description of the simulator, we need command processing (inputs) and a timer to implement filling / emptying in time. To do this, we define (redefine) only two functions:

      // Функция обработки сообщений от датчиков: 
      virtual void sensorInfo( const UniSetTypes::SensorMessage* sm ) override;  
      // Функция обработки таймеров:
      virtual void timerInfo( const UniSetTypes::TimerMessage* tm ) override;

    First, let's take a closer look at the implementation of sensorInfo ()

    void Imitator::sensorInfo( const UniSetTypes::SensorMessage* sm )
    	if( sm->id == cmdLoad_c )
    		if( sm->value )
    			myinfo << myname << "(sensorInfo): команда на 'наполнение'..." << endl;
    			askTimer(tmStep,stepTime); // запускаем таймер в работу
    	else if( sm->id == cmdUnload_c )
    		if( sm->value )
    			myinfo << myname << "(sensorInfo): команда на 'опустошение'..." << endl;
    			askTimer(tmStep,stepTime); // запускаем таймер в работу

    Everything is simple here ... the “fill” command came (cmdLoad_c = 1) - we start the timer ... (you never know before).
    The “empty” command came (cmdUnload_c = 1) - we also start the timer. All logic is contained in a timer.
    (of course, you can implement all this in a different way ... I just need to somehow demonstrate how to work with timers :))

    Let's see the implementation of timerInfo ().
     void Imitator::timerInfo( const UniSetTypes::TimerMessage* tm )
    	if( tm->id == tmStep )
    		if( in_cmdLoad_c ) // значит наполняем..
    			out_Level_s += stepVal;
    			if( out_Level_s >= maxLevel )
    				out_Level_s = maxLevel;
    				askTimer(tmStep,0); // останавливаем таймер (и работу)
    		if( in_cmdUnload_c ) // значит опустошаем
    			out_Level_s -= stepVal;
    			if( out_Level_s <= minLevel )
    				out_Level_s = minLevel;
    				askTimer(tmStep,0); // останавливаем таймер (и работу)

    When the tmStep timer is triggered (we can have any number of timers). We look at what our team is “holding” now, if it is “filled” ( in_cmdLoad_c = 1 ), then we increase out_Level_s by an increment step ( stepVal ), if we “empty” it, we decrease out_Level_s by stepVal . At the same time, check max and min.

    And now a little analysis of this whole kitchen ...

    “Where” in_cmdLoad_c, in_cmdUnload_c, out_Level_s fields appeared in the class.

    They are actually generated in a skeleton.

    For all "inputs" ( vartype = "in" see the description xml file) in the skeleton, the following fields are generated
    name - a field containing the sensor identifier with which this "input" is
    connected in_name - a field containing the current sensor value

    For all "outputs" ( vartype = "out" see the xml description file) in the skeleton, the following fields are generated
    name - a field containing the identifier of the sensor with which this "output" is
    connected out_name - a field for setting the sensor.

    how does the timer work

    In the same skeleton there is a special function askTimer (timerId, msec, count)
    timerId - identifier of the timer (this is some kind of your number so that you can distinguish timers among each other)
    msec - time for which the timer is set in milliseconds. If set to 0, then the timer is disabled.
    count - an optional parameter for how many times to trigger the timer (by default it will come every msec milliseconds until it is stopped).

    When the timer is “started”, every msec milliseconds the timerInfo function is called (const UniSetTypes :: TimerMessage * tm) to which the TimerMessage structure containing the identifier of the timer that has been triggered is passed .

    Important :
    1. it is still not realtime, and therefore timers only guarantee that they will not work “earlier” than the specified time.
    2. Timers are not asynchronous (!), Therefore. messages are processed sequentially, if you get stuck somewhere in the handler ( sensorInfo for example) by calling there some kind of sleep (xxx) then the timer will “linger” for this time.
    3. timers must be a multiple of the minimum "quantum" (step) of time specified in the xml-file in the parameter
      <set name="sleep-msec" val="150"/>
      Those. if 150 ms is indicated here, then the 50 ms timer will still work after 150 ms.

    While I suggest not paying attention to these details, about them later ...

    how sensorInfo works

    The sensorInfo () function is called whenever the value of an input changes. In fact, a notification about a change in a sensor comes from SharedMemory (if the process runs on notifications).

    So with the logic decided. It remains to write actually main ().
    Just show the code, and then comment ...

    function main ()
    #include <UniSetActivator.h>
    #include "UniSetExampleConfiguration.h"
    #include "Imitator.h"
    // -----------------------------------------------------------------------------
    using namespace UniSetTypes;
    using namespace std;
    // -----------------------------------------------------------------------------
    int main( int argc, const char** argv )
    		auto conf = uniset_init(argc, argv);
    		auto act = UniSetActivator::Instance();
    		auto im = UniSetExample::make_object<Imitator>("Imitator1", "Imitator");
    		SystemMessage sm(SystemMessage::StartUp);
    		act->broadcast( sm.transport_msg() );
    		return 0;
    	catch( const Exception& ex )
    		cerr << "(imitator): " << ex << endl;
    	catch( const std::exception& ex )
    		cerr << "(imitator): " << ex.what() << endl;
    		cerr << "(imitator): catch(...)" << endl;
    	return 1;
    // -----------------------------------------------------------------------------

    First you need to download the very configuration of the project and initialize everything necessary for libuniset to work. All this is done in the function uniset_init (argc, argv) , which returns a global pointer (shared_ptr) conf (configuration), for all needs. It can also be obtained anywhere in the program.
    auto conf = uniset_conf();

    In this example, we are not using it (explicitly, but actually using make_object ).
    In uniset_init () , the configure.xml file is loaded (the file with the same name is trying to load by default). It can be redefined either by passing the third argument to
    , or on the command line by setting the --confile myconfile.xml parameter .

    All uniset objects must be “activated”, after which they will be able to receive notifications and generally interact with the outside world. For this, there is a UniSetActivator in the system (as you can see from the code, this is singleton ). The activation process is simple: create an object and add it to the activator (more precisely, shared_ptr per object).

    To create an object of our Imitator class , as described above, we need to pass it its unique identifier and a pointer to the xml-node with the settings. For convenience and clarity, the template function make_object <> is declared in UniSetExampleConfigurationwhich is passed the textual name of the object (name in the section from configure.xml) and the name of the configuration node <XXXNodeName name = "ObectName /> for this object in configure.xml . The make_object <> function already hides all the “magic” of obtaining an ObjectId object using these parameters and finding xmlNode * in configure.xml . The example shows that in configure.xml there must exist an object (identifier) ​​with the name "Imitator1" and the configuration section <Imitator name = "Imitator1" ... />

    This is how it looks in configure.xml
    <objects name="Objects" section="Objects">
       <item id="20001" name="Imitator1"/>

    And the tuning section usually created in the settings section
          <Imitator name="Imitator1"/>

    This ended the coding of the simulator.

    Simulator configuration

    The configuration process itself consists in populating configure.xml and binding sensors to the inputs and outputs of the process. To help in this matter, there is a special utility uniset-linkeditor . This is a binding editor that allows you to graphically make bindings, as well as edit some other parameters declared in the xml description file. Itself uniset-linkeditor written in python. It is installed as a separate package. So we need to install it first
    apt-get install uniset-configurator

    The src / Algorithms / Imitator directory contains a special script,, which launches the binding editor. Actually, we need to associate our commands ( inputs ) with the CmdLoad_C and CmdUnload_C sensors , and attach the level in the tank (the output of our simulator ) to the Level_AS sensor . This can be done manually, there’s nothing complicated ... As a result, the configuration section for our simulator (in the configure.xml project file ) should take the form
    <Imitator name="Imitator1" Level_s="Level_AS" cmdLoad_c="CmdLoad_C" cmdUnload_c="CmdUnload_C"/>

    As you can see, bindings are easy to create. Each input or output is associated with a sensor in the system.


    Despite the fact that there were “many letters”, in fact, we did very little
    • Created (filled) the configure.xml project file
    • We created a file describing our simulator imitator.src.xml and generated a “skeleton" of the class from it
    • We wrote an implementation of our logic ( only two functions )
    • Wrote ( almost boilerplate ) main ()
    • Configured our simulator ( tied specific sensors )

    And that’s ... It

    remains now to try to run.
    This will be in the next part ...

    For those who are interested: