Когда-то давным-давно… Мной была написана статья «Знакомство с libuniset — библиотекой для создания АСУ», были планы по написанию продолжения, но не сложилось. С тех пор, библиотека значительно «подросла» и даже уже вышла версия 2.0, в которой появилось много новых возможностей: удалённый просмотр логов и программных переменных, поддержка различных полезных и не очень протоколов и баз, есть даже «time-machine», но об этом если до этого дойдёт…

Вообщем я собрался силами и решил, что лучше всё это «один раз увидеть» на конкретном примере.

Поэтому, кому ещё интересно, прошу.

Планируется несколько статей по этапам работы:


К сожалению, сложно создать пример реального многоузлового сетевого проекта (по крайней мере понадобится сеть виртуальных машин, установка пакетов, развёртывание и т.п.). Поэтому я рассмотрю простой пример, который можно скомпилировать и запустить локально и на нём покажу работу основных uniset-концепций.

В качестве примера возьмём следующую «сферическую» задачку:
Необходимо написать процесс управления уровнем в «цистерне»

Процесс наполняет цистерну (включает насос), и как только уровень доходит до заданного верхнего порога (95%), подаёт команду на насос, «опустошающий» цистерну. Как только уровень доходит до нижнего порога (5%), опять начинает наполнять. И так по кругу.

Работа должна начинаться после прихода(1) специальной команды «Начать работу» и завершаться при снятии(0) команды «Начать работу».

Т.к. я сам в основном работаю с ALTLinux, то все примеры будут рассмотрены под этой ОС.
Я сижу на Сизифе, но примеры будут работать и на Платформе P7
Итак...

Подготовительный шаг — создание проекта, установка пакетов


Установить в систему нужные для работы пакеты
apt-get install libuniset2-devel libuniset2-extensions-devel libuniset2-utils libomniORB-names git gitk git-gui etersoft-build-utils ccache

Теперь можно закладывать наш проект…

Важно: libuniset2 требует компилятор с поддержкой C++11.

Чтобы не сильно мучиться, я заготовил «основу», просто скачиваем http://github.com/Etersoft/uniset2-example ветка master.

Итак у нас следующая структура каталогов (конечно она может быть любой, данная структура просто «привычка»)

/Utilities — различные вспомогательные утилиты (скриптики) которыми обычно обрастает проект
/conf — файлы конфигурации проекта
/docs — документация (а как же без неё в хорошем проекте)
/include — место для общих заголовочных файлов проекта
/src — собственно исходники
/lib — место для общей проектной библиотеки (код общих функций)
configure.ac — собственно файл для сборки проекта (да-да, я использую autotools)
autogen.sh — вспомогательный скриптик чтобы сгенерить Makefile.in

и другие файлы, которые конечно должны быть в «красивом» проекте.

Запускаем
autogen.sh && configure && make

Кстати я привык использовать jmake — это такая обёртка над make из пакета etersoft-build-utils, учитывающая наличие в системе ccache и количество процессоров.

Если всё скомпилировалось, то можно следовать дальше… В целом у нас пока ещё ничего и нет.

Шаг первый — конфигурирование uniset-проекта



В uniset-проектах вся конфигурация системы хранится (обычно в одном) xml-файле. Опять же по привычке он называется configure.xml и кладётся в «conf/». Расписывать формат этого файла я не буду, цель ведь — «как можно быстрее запустить тут что-нибудь и увидеть работу», но есть описание в документации

Чтобы начать наполнять конфигурационный файл, нам надо из описания задачи понять какие «датчики» нам понадобятся в нашем проекте. В целом получается такой список:
  • команда «НАЧАТЬ РАБОТУ» — DI датчик
  • текущий уровень в цистерне — AI датчик
  • команда на включение «насоса наполнения» — DO датчик
  • команда на включение «насоса опустошения» — DO датчик

Всё это вносится с секцию sensors (id назначаем какой хотим, лишь бы уникальный).
В итоге будет примерно так:
<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="Команда на включение насоса 'опустошения'"/>
</sensors>

Можно увидеть что при внесении датчиков, мы каждому задаём уникальный идентификатор (id), уникальное имя (name) и ещё есть textname=".." — этот текст может быть использован впоследствии для GUI или других применений.
Каждый датчик имеет один из четырёх типов (iotype). Вот ссылка на документацию.
  • DI — Digital Input
  • DO — Digital Output
  • AI — Analog Input
  • AO — Analog Output

Вообще деление на такие типы немного условное, пока нет работы с реальным вводом/выводом. Но сами по себе типы, определяют чем тот или иной датчик являются с точки зрения проектируемой системы. «Входы» — это то, что «входит в систему», «выходы» — это то «выходит» (то есть то, что формирует система).

С датчиками предварительно разобрались…

Шаг второй — создание имитатора



На самом деле, чтобы нам отлаживать работу нашего процесса управления, нам понадобиться ещё написать и маленький имитатор. По сути тот же процесс управления, только имитирующий «необходимое оборудование». Поэтому начнём с него, как более простого процесса (с натяжкой будем считать что мы поклонники TDD).
В итоге у нас появляется в проекте ещё два каталога

src/Algorithms/Controller — процесс управления (решающий поставленную задачу)
src/Algorithms/Imitator — имитатор для наладки процесса управления

Поскольку там есть ещё пару служебных каталогов (о которых будет позже рассказано), то я выделяю процессы в отдельный подкаталог «Algorithms».

Итак задача имитатора (самый простой «тупой» вариант):
  • По команде «наполнять» имитировать наполнение цистерны (увеличение значения аналогового датчика уровня в цистерне).
  • По команде «опустошать» имитировать опустошение цистерны (уменьшение значения аналогового датчика уровня в цистерне).

Создание xml-файла описывающего имитатор


Настало время рассказать об одной вспомогательной, но очень важной uniset-утилите: uniset2-codegen.
С помощью неё на основе специального xml-описания генерируется «скелет» процесса, содержащий всю «рутинную часть» работы. После чего, «наследуясь» от сгенерированного класса (скелетона), достаточно лишь реализовать необходимую функциональность (переопределив виртуальные функции).

Файл xml-описания представляет из себя простой xml-файлик в котором описываются «входы» и «выходы»
для данного процесса (если считать его «чёрным ящиком»). Важно понимать, что:
  • «входы» и «выходы» это по сути датчики которые ваш процесс «получает» (входы) или «выставляет»(выходы)
  • что является «входом» для одного процесса, то вполне может быть «выходом» для другого.
  • входы/выходы — это не сами датчики, а просто поля класса, которые в последствии (во время старта) привязываются к конкретным датчикам.

Чтобы всё это осознать, давайте сразу к делу. Для имитатора у нас есть:
два входа — это команды наполнять/опустошать и один выход — это собственно имитируемый уровень в цистерне. Тогда его часть xml-файла описывающая входы/выходы будет выглядеть следующим образом:

  <smap>
      <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="Команда начать «опустошение»">
  </smap>

name — задаёт название «переменной» (в скелетоне класса); из этого названия будут сгенерированы поля класса содержащие (id датчика к которому привязан данный вход/выход), а также переменная содержащая текущее значение.
vartype — определяет тип переменной «вход» или «выход». Вход это то, что «читается», «выход» — то что «пишется».
comment — превращается в комментарий в стиле doxygen (/*!< ххххх */)

Соответственно в целом мы можем впоследствии запустить несколько имитаторов, каждый из которых будет привязан к своим датчикам…

Для тех кому интересно подробнее..
полная версия файла описания выглядит так:
<?xml version="1.0" encoding="utf-8"?>
<!--
	name 		- название класса
	msgcount	- сколько сообщений обрабатывается за один раз
	sleep_msec	- пауза между итерациями в работе процесса

	type
	====
		in 	- входы (только для чтения)
		out	- выходы (запись)
-->
<Imitator>
  <settings>
	<set name="class-name" val="Imitator"/>
	<set name="msg-count" val="30"/>
	<set name="sleep-msec" val="150"/>
  </settings>
  <variables>
		<item name="stepVal" type="long" const="1" default="6" comment="Шаг наполнения (скорость)"/>
		<item name="stepTime" type="long" const="1" default="500" comment="Время на один шаг, мсек"/>
  </variables>
  <smap>
		<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="Команда начать 'опусташение'"/>
  </smap>
  <msgmap>
  </msgmap>
</Imitator>  
  


Написание кода имитатора


Сформировав входы/выходы мы можем теперь сгенерировать скелетон. Не вдаваясь в подробности, это делается командой:
uniset2-codegen -n Imitator --ask --no-main imitator.src.xml

параметр --ask — говорит генерировать процесс на основе уведомлений об изменении (заказ датчиков)
параметр --no-main — говорит не генерировать main.cc т.к. мы напишем свой.
параметр -n — это название класса для которого генерируется скелет.
Вообще эта команда добавляется в
Makefile.am
bin_PROGRAMS = imitator

BUILT_SOURCES = Imitator_SK.h Imitator_SK.h
 
imitator_LDADD = $(top_builddir)/lib/libUniSetExample.la
#imitator_CPPFLAGS = 
imitator_SOURCES = Imitator_SK.cc Imitator.cc imitator-main.cc

Imitator_SK.h Imitator_SK.cc: imitator.src.xml
	@UNISET_CODEGEN@ -n Imitator --topdir $(top_builddir)/ --ask --no-main imitator.src.xml

clean-local:
	rm -rf *_SK.cc *_SK.h *.log


В итоге будет сгенерировано два файла:
Imitator_SK.h — заголовочный
Imitator_SK.cc — реализация

Это очень интересные файлы, для изучения чего же там делается, но пока нам не до них…
Итак мы закладываем собственно нашу реализацию. Создаём два файла в которых и будет реализована наша логика работы.
Imitator.h
Imitator.cc

Давайте посмотрим на Imitator.h подробнее.
Imitator.h
#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
{
	public:
		Imitator( UniSetTypes::ObjectId id, xmlNode* cnode, const std::string& prefix = "" );
		virtual ~Imitator();

		enum Timers
		{
			tmStep
		};

	protected:
		virtual void sensorInfo( const UniSetTypes::SensorMessage* sm ) override;
		virtual void timerInfo( const UniSetTypes::TimerMessage* tm ) override;

	private:

};
// -----------------------------------------------------------------------------
#endif // Imitator_H_


В uniset-системе, каждый объект, который хочет получать уведомления о датчиках и вообще как-то взаимодействовать с внешним миром, должен обладать уникальным идентификатором в рамках системы. Помимо этого нашему процессу необходимо входы/выходы связать уже с конкретными датчиками, для этого в configure.xml у каждого процесса имеется своя настроечная секция.
В итоге… в конструкторе мы передаём идентификатор объекта, а так же указатель на конкретный xml-узел с настройками данного процесса. Помимо этого есть ещё prefix (это для обработки аргументов командной строки, дальше будет показано как этим пользоваться).

Исходя из описания имитатора нам нужна обработка команд (входы) и таймер для реализации наполнения/опустошения во времени. Для этого определим (переопределим) всего лишь две функции:

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

Для начала посмотрим внимательнее реализацию 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); // запускаем таймер в работу
		}
	}
}

Здесь всё просто… пришла команда «наполнять» (cmdLoad_c=1) — запускаем таймер… (мало ли до этого спали).
Пришла команда «опустошать» (cmdUnload_c=1) — тоже запускаем таймер. Вся логика содержится в таймере.
(естественно можно это всё реализовать и по-другому… просто мне нужно как-то продемонстрировать работу с таймерами :))

Посмотрим реализацию 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); // останавливаем таймер (и работу)
			}
			return;
		}

		if( in_cmdUnload_c ) // значит опустошаем
		{
			out_Level_s -= stepVal;
			if( out_Level_s <= minLevel )
			{
				out_Level_s = minLevel;
				askTimer(tmStep,0); // останавливаем таймер (и работу)
			}
			return;
		}
	}
}

При срабатывании таймера tmStep (а таймеров у нас может быть сколько угодно). Мы смотрим какая у нас сейчас «держится» команда, если «наполнять» (in_cmdLoad_c=1), значит, увеличиваем out_Level_s на шаг приращения (stepVal), если «опустошать» — уменьшаем out_Level_s на шаг stepVal. Заодно проверяем max и min.

А теперь небольшой разбор всей этой кухни...

«откуда» в классе появились поля in_cmdLoad_c, in_cmdUnload_c, out_Level_s.

Они на самом деле сгенерированы в скелетоне.

По всем «входам» (vartype=«in» см. xml-файл описания) в скелетоне генерируются следующие поля
name — поле, содержащее идентификатор датчика с которым связан этот «вход»
in_name — поле, содержащее текущее значение датчика

По всем «выходам» (vartype=«out» см. xml-файл описания) в скелетоне генерируются следующие поля
name — поле, содержащее идентификатор датчика, с которым связан этот «выход»
out_name — поле для выставления датчика.

как работает таймер

В том же скелетоне есть специальная функция askTimer(timerId,msec,count)
timerId — идентификатор таймера (это какое-то ваше число, чтобы вы могли отличать таймеры между собой)
msec — время, на которое «взводится таймер» в миллисекундах. Если задать 0 — то таймер отключается.
count — необязательный параметр сколько раз сработать таймеру (по умолчанию он будет приходить каждые msec миллисекунд, пока его не остановят).

Когда таймер «запущен», каждые msec милисекунд вызывается функция timerInfo( const UniSetTypes::TimerMessage* tm ) в которую передаётся структура TimerMessage содержащая идентификтор сработавшего таймера.

Важно:
  1. это всё-таки не realtime и поэтому таймеры гарантируют лишь то, что они не сработают «раньше» указанного времени.
  2. Таймеры не асинхронные(!), поэтому т.к. обработка сообщений ведётся последовательно, если вы застрянете где-то в обработчике (sensorInfo например) вызвав там какой-нибудь sleep(xxx) то и таймер «задержится» на это время.
  3. таймеры должны быть кратны минимальному «кванту» (шагу) времени, указанному в xml-файле в параметре
    <set name="sleep-msec" val="150"/>
    Т.е. если тут указано 150 мсек, то таймер 50 мсек сработает всё равно через 150 мсек.

Пока предлагаю не обращать внимание на эти детали, о них потом…

как работает sensorInfo

функция sensorInfo() вызывается всякий раз когда меняется значение какого либо «входа». На самом деле, уведомление об изменении того или иного датчика приходит от SharedMemory (если процесс работает по уведомлениям).

Итак с логикой определились. Осталось написать собственно main().
Просто покажу код, а дальше прокоментирую…

функция main()


main.cc
#include <UniSetActivator.h>
#include "UniSetExampleConfiguration.h"
#include "Imitator.h"
// -----------------------------------------------------------------------------
using namespace UniSetTypes;
using namespace std;
// -----------------------------------------------------------------------------
int main( int argc, const char** argv )
{
	try
	{
		auto conf = uniset_init(argc, argv);
		auto act = UniSetActivator::Instance();

		auto im = UniSetExample::make_object<Imitator>("Imitator1", "Imitator");
		act->add(im);

		SystemMessage sm(SystemMessage::StartUp);
		act->broadcast( sm.transport_msg() );

		act->run(false);
		return 0;
	}
	catch( const Exception& ex )
	{
		cerr << "(imitator): " << ex << endl;
	}
	catch( const std::exception& ex )
	{
		cerr << "(imitator): " << ex.what() << endl;
	}
	catch(...)
	{
		cerr << "(imitator): catch(...)" << endl;
	}

	return 1;
}
// -----------------------------------------------------------------------------


Для начала необходимо загрузить ту самую конфигурацию проекта и инициализировать всё необходимое для работы libuniset. Всё это делается в функции uniset_init(argc,argv), которая возвращает глобальный указатель (shared_ptr) conf (конфигурация), для всяких нужд. Так же его можно получить в любом месте программы
auto conf = uniset_conf();

В данном примере мы не используем его (явным образом, но на самом деле используем в make_object).
В uniset_init() происходит загрузка файла configure.xml (файл с таким названием идёт попытка загрузки по умолчанию). Его можно переопределить либо передав третьим аргументом в
uniset_init(argc,argc,"myconfigure.xml")
, либо в командной строке задав параметр --confile myconfile.xml.

Всё uniset-объекты должны «активироваться», после чего они смогут получать уведомления и вообще взаимодействовать с внешним миром. Для этого в системе существует UniSetActivator (как видно из кода это singleton). Процесс активации простой: создаём объект и добавляем его в активатор (точнее shared_ptr на объект).

Чтоб создать объект нашего класса Imitator, как было описано выше, нам нужно передать ему его уникальный идентификатор и указатель на xml-узел с настройками. Для удобства и наглядности, в UniSetExampleConfiguration объявлена шаблонная функция make_object<> которой передаётся текстовое название объекта (name в секции из configure.xml) и название конфигурационного узла <XXXNodeName name=«ObectName /> для данного объекта в configure.xml. В функции make_object<> уже скрыта вся «магия» получения по этим параметрам ObjectId объекта и нахождение xmlNode* в configure.xml. Из примера видно, что в configure.xml должен существовать объект (идентификатор) с именем »Imitator1" и настроечная секция <Imitator name=«Imitator1» .../>

Вот как это выглядит в configure.xml
<objects name="Objects" section="Objects">
    ...
   <item id="20001" name="Imitator1"/>
   ...
</objects>

И настроечная секция обычно создаваемая в секции settings
  <settings>
      <Imitator name="Imitator1"/>
     ...
  </settings>

На этом кодирование имитатора закончилось.

Конфигурирование имитатора


Сам по себе процесс конфигурирования заключается в наполнении configure.xml и привязках датчиков ко входам и выходам процесса. Для помощи в этом деле существует специальная утилита uniset-linkeditor. Это редактор привязок, позволяющий в графическом виде осуществить привязки, а так же редактировать некоторые другие параметры объявленные в xml-файле описания. Сам по себе uniset-linkeditor написан на python. Он устанавливается отдельным пакетом. Так что нам надо его сперва установить
apt-get install uniset-configurator

В каталоге src/Algorithms/Imitator лежит специальный скриптик edit_imitator.sh запускающий редактор привязок. Собственно нам необходимо связать наши команды (входы) с датчиками CmdLoad_C и CmdUnload_C, а уровень в цистерне (выход нашего имитатора) привязать к датчику Level_AS. Это можно сделать и вручную, в этом нет ничего сложного… В итоге настроечная секция для нашего имитатора (в файле проекта configure.xml) должна принять вид
<Imitator name="Imitator1" Level_s="Level_AS" cmdLoad_c="CmdLoad_C" cmdUnload_c="CmdUnload_C"/>

Как видно привязки создать просто. Каждому входу или выходу ставить в соответствии датчик в системе.

Промежуточный итог


Несмотря на то, что было «много букв», на самом деле мы сделали совсем немного
  • Создали(заполнили) файл проекта configure.xml
  • Создали файл описывающий наш имитатор imitator.src.xml и сгенерировали по нему «скелет» класса
  • Написали реализацию нашей логики (всего две функции)
  • Написали (почти шаблонный) main()
  • Сконфигурировали наш имитатор (привязали конкретные датчики)

И всё…

Осталось теперь попробовать запустить.
Это будет уже в следующей части...

Для тех кого заинтересовало:

Комментарии (5)


  1. prefrontalCortex
    05.03.2016 12:03

    Так и не смог найти, какая лицензия у библиотеки. GPL или таки что-то невирусное?


    1. PavelVainerman
      05.03.2016 12:16
      +1

      С лицензией вопрос остался нерешённым до конца (не хватает знаний в этой области). Изначально планировалось LGPL или что-то более свободное. Но нужно чтобы это не противоречило всем остальным используемым в libuniset библиотекам. Поэтому этот вопрос как-то "подзавяз".


      1. PavelVainerman
        05.03.2016 12:20
        +1

        Сейчас формально GPL


        1. prefrontalCortex
          05.03.2016 13:55

          Ясно :(


          1. PavelVainerman
            05.03.2016 20:16
            +1

            Вы знаете… провёл ревизию этого вопроса (нашлись добрые люди). Лицензия LGPL. Привёл в порядок исходники.