Как написать OPC DA сервер
Статья от Олег А. Иванов |  2012-05-31 00:33:17
OPC - интерфейс интеграции разнородных систем и устройств с различными протоколами обмена. В настоящее время механизм и спецификация OPC является основным инструментом для обмена данными в системах автоматицации и учета. OPC DA в этом отношении является уже несколько устаревшим, но пока еще самым распространенным стандартом. На смену ему уже несколько лет наступает новый, объединенный, мультиплатформенный стандарт OPC UA. Кроме этого существуют еще две менее распространенных спецификации OPC HDA (для запроса архивных данных, то есть когда один тег имеет еще одно измерение - время) и OPC AE (специфический стандарт для передачи тревог и событий). Я не писал серверов OPC HDA и AE, поэтому ничего полезного рассказать вам не смогу.

Вы скажете, а как же базовая задача получения данных с устройств-вычислителей, которые накапливают и хранят данные по каналам во времени, их ведь невозможно передать через OPC DA? Вы будете правы - действительно невозможно, но я в этом случае немного хитрил и передавал временной срез последовательно внутри одного тега или кодированно в одном, а в таком случае задача распаковки данных ложилась на OPC клиента, что во многих случаях позволяло реализовывать обработку данных внутри них.

Вернемся к написанию простуйшего OPC DA сервера для типового устройства. Кстати это совсем необязательно, что сервер пишется для интеграции именно устройств. Нет, на другой стороне может быть все, что угодно от базы данных, до текстовых файлов или даже человека, который эти данные вводит через любой интерфейс. В общем случае нам нужно знать лишь аппаратный интерфейс взаимодействия с удаленным объектом и протокол обмена с ним.

Итак мы имеем:
некое устройство с последовательным интерфейсом
протокол обмена происходит в режиме запрос-ответ
мастером является устройство, клиент-сервер подключается и запрашивает данные

Первое, что нам понадобиться среда разработки. Я буду использовать самую распространенную Visual Studio 2008 (2005,2010 итп). Итак, среда разработки установлена, устройство подключено, с чего начать.

Разумнее всего начинать с ознакомления с протоколом обмена. Его необходимо распечатать, чтобы был всегда под рукой. Далее, если для устройства существует программное обеспечение (конфигураторы, программы сбора) - смело ставим их. Первая из утилит, которая нам понадобиться - монитор порта обмена. Я использую старенькую Portmon (1999 Mark Russinovich http://www.sysinternals.com). Не самая удачная программа, так как нехватает многих функций типа просмотра флагов всех регистров последовательного порта и бывает отъедает много памяти при долгой наработке, но меня она устраивает уже много лет.



Естественно для других интерфейсов в ход пойдут другие программы, например для ethernet снифферы. Для протокола modbus полезно сразу установить несколько утилит, которые позволят опрашивать все регистры, что бы проверить собственные посылки на корректность.

Так, как наше устройство работает по последовательному интерфейсу, запускаем монитор порта и открываем нужный порт. Запускаем сервисное программное обеспечение и последовательно проделываем все важные операции из числа возможных, такие как
  • запрос даты и времени устройства
  • запрос информации об устройстве
  • запрос текущих значений
  • запрос статуса устройства
После каждого действия необходимо копировать переданные и полученные посылки в отдельный документ с комментарием произведенного действия и очищать буфер в мониторе для дифференциации запросов.

Это будет необходимо если возникнут проблемы при отладке сервера и:
  • сравнения посылок, генерируемых собственным сервером и сервисным ПО
  • сравнения регистров обмена, таймаутов между посылками
  • чтобы удостовериться, что дело не в коде

Сервер буду писать на базе привычной для меня библиотеки lighopc (www.ipi.ac.ru/lab43/lopc-ru.html). Существет немало других библиотек и каркасов серверов и клиентов, которые можно найти в интернете, со многими я в сове время поработал, но остановился именно на lightopc из за его простоты. Этот проект давно уже не развивается и не поддерживает иные спецификации кроме OPC DA.

Теперь приступаем непосредственно к написанию сервера. Запускаем VS, выбираем тип проекта - консольное приложение, а сам проект оставляем пустым. Я использую консольное приложение, посколько в старое добрую консоль удобно выводить то, чем в данный момент занимается сервер, включая текущие значения параметров, запросы/ответы от устойств или его собственные ошибки.



Создаем исходный пустой файл сервера. Далее я не буду углубляться в проектирование и написание классов, распределение их по отдельным файлам и модулям, так как моя задача лишь показать саму суть, то есть создать максимально простой образец сервера. Итак, минимальный проект OPC сервера будет состоять из исходного файла на языке C, заголовочного файла, файла интерфейсов и нескольких библиотек.

Создаем и добавляем в проект файлы:
- device.h
- device.cpp

Необходимо скачать с сайта lighopc (www.ipi.ac.ru/lab43/lopc-ru.html) собранные библиотеки lightopc-0.888-0313bin. Внутри архива будут файлы lightopc.dll, lightopc.lib, их нужно добавить в проект.
Оттуда же скачиваем библиотеку для ведения лог-файлов сервера unilog-0.55-1227. Внутри будут файлы unilog.dll и unilog.lib, их также добавляем в проект.
Далее нам понадобиться библиотека opcda из SDK (opcfoundation.org), ее заголовочный суем туда же и линкуем из программы.
#include "opcda.h"
Программный модуль последовательного интерфейса можно использовать любой из множества представленных в инете или написать свой класс. Я буду использовать небольшой класс с соответствующим названием serialport и типовыми функциями открытия, закрытия порта, записи и чтения из порта. Исходный коды можно посмотреть в архиве, который прилагается к этой статье.

Это наиболее правильное решение, хотя еще гибче будет создать прослойку в виде модуля, который в зависимости от типа интерфейса будет использовать те или иные функции для работы с ним, а внешние вызовы из основной программы остануться одинаковыми. Это позволит иметь один исходный код и легко переключаться между типами интерфейсов, или даже совмещать при работе одного сервера.
Если будет использоваться конвертер интерфейсов (RS-CAN, RS-Ethernet), то к проекту потребуется подключить соответствующую библиотеку с API.

Для работы серверу требуется задать уникальный идентификатор GUID, который можно сгенерировать с помощью специальной стандартной утилитки guidgen.exe, которая входит в состав Visual Studio (Microsoft Visual Studio 9.0\Common7\Tools). Выбираем 2. DEFINE_GUID -> Copy и вставляем результат из буфера в исходный файл под другими определениями.

// {76B8B688-38B1-4802-9C21-1E34A3097778}
DEFINE_GUID(GID_UnknownDeviceOPCserverExe,
0x76b8b688, 0x38b1, 0x4802, 0x9c, 0x21, 0x1e, 0x34, 0xa3, 0x9, 0x77, 0x78);




Что точно потребуется определить.
#define _WIN32_DCOM // Разрешает расширения DCOM
#define INITGUID // Инициализирует OLE константы
#define ECL_SID "opc.device" // идентификатор OPC сервера

Точно потребуются следующие библиотеки.
#include // стандартный ввод-вывод
#include // математические функции
#include "server.h" // заголовочный файл этого сервера
#include "unilog.h" // библиотека для лог-файлов
#include "opcda.h" // базовые функции OPC:DA
#include "lightopc.h" // заголовлочный файл light OPC


Определяем переменные для lightopc.
static const loVendorInfo vendor = {0,1,8,0,"Unknown device OPC Server" }; // версия сервера (Major/Minor/Build/Reserv)
static int OPCstatus=OPC_STATUS_RUNNING; // статус OPC сервера
loService *my_service; // экземпляр lightOPC сервера

Память под переменные - теги сервера можно выделять динамически, а можно взять с запасом статически, TAGS_NUM_MAX - максисмальное количество тегов.
static CHAR *tn[TAGS_NUM_MAX]; // Названия тегов
static loTagValue tv[TAGS_NUM_MAX]; // Значения тегов
static loTagId ti[TAGS_NUM_MAX]; // Идентификаторы тегов

#define LOGID logg,0 // идентификатор лог-файла
#define LOG_FNAME "ecl.log" // имя лог-файла
unilog *logg=NULL; // указатель на создаваемый файл журнала
Еще немного об основах. Правилом хорошего тона является предоставление пользователю удобного механизма для редактирования свойств сервера (параметров обмена, механизма формирования тегов, итп). Можно написать отдельное приложение для формирования конфигурационного файла сервера, которое может совмещать в себе функции также и OPC клиента, отображающего данные. Это вполне обособленная и достаточно тривиальная задача, которую я не буду разбирать. В любом случае конфигуратор на выходе будет иметь файл с настройками сервера и очень важно, чтобы этот файл был правильно структурирован, читабелен и была возможность его ручного редактирования. Оптимальным выбором будет использование формата xml для хранения данных. В этом случае отлично подойдет бесплатная библиотека tinyxml для парсинга xml-файлов (http://sourceforge.net/projects/tinyxml/).

#include "tinyxml/tinyxml.h" // XML parser lib



Базовый класс myClassFactory: public IClassFactory и его типвые функции я обычно выделяю в отдельный файлик, чтобы он не мозолил глаза. Он неизменен и его код вам на первых порах разбирать не потребуется. Просто скачиваем и вставляем перед основным кодом.

Все возможные входы в программу ведут к нашей функции mymain.


INT APIENTRY WinMain(HINSTANCE hInstance,HINSTANCE hPrevInstance,LPSTR lpCmdLine,INT nCmdShow)
{
  static char *argv[3] = { "dummy.exe", NULL, NULL };	// аргументы по-умолчанию
  argv[1] = lpCmdLine;					
  return mymain(hInstance, 2, argv);
}

INT main(INT argc, CHAR *argv[])
{  return mymain(GetModuleHandle(NULL), argc, argv); }


Далее разбираем содержимое функции main, прямо по пунктам.
INT mymain(HINSTANCE hInstance, INT argc, CHAR *argv[]);

1) Сходу создаем лог-файл, чтобы записывать туда все действия сервера. На отладку у каждого из нас есть свои взгляды. Я, например, люблю писать все действия программы и все промежуточные значения переменных в логи, чтобы потом апализировать поведение программы шаг за шагом и только уже если возникает какая-то коллизия, тогда делаю пошаговую отладку в встроенном дебаггере.

Итак, создаю файл, здесь LOG_FNAME - имя файла.
logg = unilog_Create(ECL_SID, LOG_FNAME, NULL, 0, ll_DEBUG); // level [ll_FATAL...ll_DEBUG]
UL_INFO((LOGID, "Unknown device OPC server start"));}


2) Что должен делать наш сервер будучи запущеным? Прежде всего работать, а работает сервер в связке с OPC-клиентом (нет, он конечно может молотить и вхолостую, но без подключенных клиентов это не всегда будет иметь смысл). Хотя я оставлю этот момент на ваше усмотрение. В конце концов никто не запрещает вам написать сервер, который будет работать всегда и даже без подключенных клиентов и заниматься чем то не менее полезным, чем предоставление данных. Например, я интегрировал в сервер функцию записи данных в текстовые файлы и базу данных, а клиентам передавал эти данные по мере их подключения. Это не совсем корректно с точки зрения иделолгии, но весьма практично.
Для того, чтобы клиенты могли подключиться к серверу им необходимо знать есть ли нужный сервер в списке зарегистрированных серверов на локальной или удаленной машине. Для этого нашему устройству необходимо зерегистрироваться в системе.
За это отвечает функция loServerRegister(&GID_ECLOPCserverExe, eProgID, eClsidName, argv0, 0)
GID_ECLOPCserverExe - сгенерированный ранее UID
eProgID,eClsidName - идентификатор, имя сервера - const char eProgID [] = ECL_SID; const char eClsidName [] = ECL_SID;
argv0 - командная строка (собственно этот путь и зарегистрирует операционная система в реестре)
Запуск этой функции предлагаю осуществлять традиционным способом - путем передачи ключа в командной строке.
Например, ключ /r или /register, будет давать понять серверу, что требуется зарегистрировать сервер в системе.
Ключ /u или /unregister, будет давать понять серверу, что требуется удалить сервер из системы.

За удаление сервера из системы отвечает функция loServerUnregister(&GID_ECLOPCserverExe, eClsidName).
Не забываем удалить за собой созданный экземпляр лога
unilog_Delete(logg); logg = NULL;
В иных случаях сервер должен работать в штатном режиме.

3) Перед началом работы инициализируем ключевую библиотеку COM объектов функцией CoInitializeEx(NULL, COINIT_MULTITHREADED).

4) Дальше опять же существует два конкретных варианта развития событий. Мы либо пытаемся инициализировать интерфейс, попытаться найти устройство и в случае успеха двигаться дальше, а в случае неудачи закончить работу сервера. Либо мы в любом случае регистрируем экземпляр сервера, не дожидаясь готовности интерфейса и ответа от устройства и работаем вхолостую, ожидая его подключения. В нашем случае не сильно принципиально, поэтому для начала запускаю функцию инициализации интерфейса InitDriver(), описание которой приведу позже. Если инициализация проваливается, не забываю кроме удаления экзепляра модуля логов ище и провести деинициализацию COM CoUninitialize(). Также удаляем и сам loService - loServiceDestroy(my_service).

5) Создание и заполнение структуры драйвера опроса логически выделено в функцию InitDriver(), вместе с инициализацией интерфейса.

 loDriver ld;			// структура драйвера
 LONG ecode;			// код возможной ошибки
 tTotal = TAGS_NUM_MAX;		// общее количество тегов
 if (my_service) {		// а вдруг сервис уже проинициализирован
      UL_ERROR((LOGID, "Driver already initialized!"));
      return 0;
  }
 memset(&ld, 0, sizeof(ld));    // выделяем память
 ld.ldRefreshRate =5000;	// время обновления данных
 ld.ldRefreshRate_min = 4000;	// минимальное время обновления данных
 ld.ldWriteTags = WriteTags;	// указатель на функцию записи значения в тег
 ld.ldReadTags = ReadTags;	// указатель на функцию чтения значений
 ld.ldSubscribe = activation_monitor;	// указатель на функцию мониторинга значений
 ld.ldFlags = loDF_IGNCASE;	// игнорируем регистр
 ld.ldBranchSep = '.';		// разделитель уровней иерархии внутри имен тегов
 ecode = loServiceCreate(&my_service, &ld, tTotal);	// функция создает экземпляр сервиса 

Производить парсинг конфигурационного файла, открывать и настраивать последовательный порт я не буду. В конце концов мы же не с реальным устройством будем работать и загромождать программу пока не буду. Если потребуется, то опишу этот раздел позже.

6) Инициализируем теги. Создам два фиктивных устройства по 3 тега на каждый. Первый тег будет целочисленным, а остальные два с плавающей запятой.
Здесь tag_add - счетчик добавляемых тегов.

 tn[tag_add] = new char[DATALEN_MAX];	// выделил память под название тега
 sprintf(tn[tag_add],"COM%d_.UnknownDevice%d.param%d",1,i,r); // записываем имя тега
 rights=OPC_READABLE | OPC_WRITEABLE; 	// тег будет на запись и на чтение
 VariantInit(&tv[tag_add].tvValue); 	// инициализируем значение
 lcid = MAKELCID(0x0409, SORT_DEFAULT); // конвертируем имя
 MultiByteToWideChar(CP_ACP, 0,tn[tag_add], strlen(tn[tag_add])+1,buf, sizeof(buf)/sizeof(buf[0])); 

 if (r==0)
	{
	 V_I2(&tv[tag_add].tvValue) = 0;
	 V_VT(&tv[tag_add].tvValue) = VT_I2; 	// целое значение
	}
 if (r>0)
	{
	 V_R4(&tv[tag_add].tvValue) = 0.0;
	 V_VT(&tv[tag_add].tvValue) = VT_R4;	// с плавающей запятой
	}
 // добавляем тег к нашему сервису
 ecode = loAddRealTag_aW(my_service, &ti[tag_add], (loRealTag)(tag_add), buf, 0, rights, &tv[tag_add].tvValue, 0, 0); 
 tv[tag_add].tvTi = ti[tag_add];	// номер тега в списке сервиса
 tv[tag_add].tvState.tsTime = ft;	// текущее время и дата (FILETIME)
 tv[tag_add].tvState.tsError = S_OK;	// все нормально
 tv[tag_add].tvState.tsQuality = OPC_QUALITY_NOT_CONNECTED;	// пока выставляем статус - нет подключения


7) Переходим к инициализации сервера в системе, чтобы другие процессы-клиенты смогли подключиться к нашему серверу. CoRegisterClassObject(GID_UnknownDeviceOPCserverExe, &my_CF, CLSCTX_LOCAL_SERVER|CLSCTX_REMOTE_SERVER|CLSCTX_INPROC_SERVER, REGCLS_MULTIPLEUSE, &objid)
my_CF - экземпляр класса myClassFactory, основного класса сервера, который мы определили выше (у меня он выделен в отдельный файл opc_main.h).
Флаги CLSCTX описаны здесь (http://msdn.microsoft.com/en-us/library/windows/desktop/ms693716%28v=vs.85%29.aspx), я выставил возможность работы как локально, так и удаленно, если клиент будет подключаться по сети.

8) При подключении нового клиента счетчик увеличивается на единицу, что и делает функция класса myClassFactory -> AddRef (), my_CF.Release() - наоборот служит обратной цели и уничтожает одного клиента, уменьшая счетчик на единицу. Пока количество, подключенных клиентов, больше нуля - функция in_use возвращает еденицу.

9) Все. Теперь мы можем полноценно работать. Например, опрашивать устройство в цикле.
while(my_CF.in_use()) if (WorkEnable) poll_device(); То есть пока хотя бы один клиент подключен к серверу и собственный флаг сервера установлен мы в цикле производим опрос устройства. Собственный флаг WorkEnable я ввожу, чтобы иметь возможность в любой момент прекратить работу сервера не дожидаясь отключения всех клиентов.

10) Функция опроса данных poll_device() - основная функция сервера, в которой объеденен опрос устройства и обновление значений тегов. Если вы будете использовать несколько экземпляров одного интерфейса (да даже если и не планируете делать это сейчас), то потребуется выделить каждому отдельный поток, иначе формирование/обработка запросов/ответов будет производиться последовательно в одном, что крайне негативно скажется на скорости работы всего сервера. Проще говоря каждому COM-порту, каждому сокету и каждому клиенту мы выделяем отдельный поток, который будет заниматься обменом данными по этому интерфейсу.
Вот, пример организации такой работы:


 for (UINT i=0; i<com_num; i++)
	{
	 hThrd[i+1] = CreateThread (NULL, 0, (LPTHREAD_START_ROUTINE) PollDeviceCOM, (LPVOID)Com[i].num, 0, &dwThrdID);
	 ReadEndFlag[i+1]=false;
	}

Мы вообще ничего читать не собираемся, поэтому значения будем формировать случайным образом, с помощью генератора случайных чисел.

 GetSystemTimeAsFileTime(&ft); 		// текущее время в формате FILETIME
 for (UINT ci=0;ci<tag_num; ci++)	
	{
	 UINT i2value=0;
	 FLOAT r4value=0.0;
	 i2value=rand() % 10;           // случайное целое число
         r4value=rand()/100;		// случайное число с плавающей запятой

	 VARTYPE tvVt = tv[ci].tvValue.vt;
	 VariantClear(&tv[ci].tvValue);	  
	 switch (tvVt)
		{
		 case VT_I2: V_I2(&tv[ci].tvValue) = (UINT) i2value; break;
		 case VT_R4: V_R4(&tv[ci].tvValue) = (FLOAT) r4value; break;
		}

	 V_VT(&tv[ci].tvValue) = tvVt;
	 tv[ci].tvState.tsQuality = OPC_QUALITY_GOOD;  	// статус значения хороший
	 // tv[ci].tvState.tsQuality = OPC_QUALITY_UNCERTAIN;		// другие варианты статуса - неопределен можно выставить в случае его некорректности или выходы за пределы измерения
	 // tv[ci].tvState.tsQuality = OPC_QUALITY_CONFIG_ERROR; 	// ошибка в конфигурации
	 tv[ci].tvState.tsTime = ft;
	}
 // функция loCacheUpdate обновляет значения всех или части тегов в кэше.
 loCacheUpdate(my_service, tag_num, tv, 0);


11) Запуск сервера. Для запуска сервера потребуется любой OPC клиент. Я уже давно использую удобный и бесплатный Matrikon OPC Explorer (http://www.matrikonopc.com/products/opc-desktop-tools/opc-explorer.aspx). Но еще до запуска сервера мы должны проделать ряд операций.
Во-первых не забываем переписать библиотеки, которые использует наша программа (unilog.dll, lighopc.dll) в директорию с исполняемым файлом.
Во-вторых необходимо зарегистрировать сервер с системе, для чего запустить его с ключом /r (fistopc.exe /r). Если все пройдет гладко то появится окно, уведомляющее об успешном завершении операции и в рабочей или системной директории (%SYSTEMROOT%\SYSTEM32) появится лог-файл (device.log).

Запускаем OPC клиент. В левой части показывается дерево зарегистрированных в системе OPC серверов. Выбираем из них наш сервер opc.device и подключаемся к нему. Если соединение происходит, создаем группу тегов и добавляем все теги в режим просмотра. Должно получиться чтото вроде этого.



Пример лог-файла правильно работающего сервера.

2012/05/31 11:21:24.953 I4 Unknown Device OPC server start
2012/05/31 11:21:24.953 I4 CoInitializeEx() [ok]
2012/05/31 11:21:24.953 I4 InitDriver() start
2012/05/31 11:21:24.953 T6 loServiceCreate()=  No error (0)
2012/05/31 11:21:24.953 I4 init_tags(2 | 6)
2012/05/31 11:21:24.953 E1 (0/2) device0
2012/05/31 11:21:24.953 T6 loAddRealTag(COM1.UnknownDevice0.param0) = 1 [0]  No error (0)
2012/05/31 11:21:24.953 T6 loAddRealTag(COM1.UnknownDevice0.param1) = 2 [1]  No error (0)
2012/05/31 11:21:24.953 T6 loAddRealTag(COM1.UnknownDevice0.param2) = 3 [2]  No error (0)
2012/05/31 11:21:24.953 E1 (1/2) device1
2012/05/31 11:21:24.953 T6 loAddRealTag(COM1.UnknownDevice1.param0) = 4 [3]  No error (0)
2012/05/31 11:21:24.953 T6 loAddRealTag(COM1.UnknownDevice1.param1) = 5 [4]  No error (0)
2012/05/31 11:21:24.953 T6 loAddRealTag(COM1.UnknownDevice1.param2) = 6 [5]  No error (0)
2012/05/31 11:21:24.953 E1 driver_init()=  No error (0)
2012/05/31 11:21:24.953 I4 InitDriver() [ok]
2012/05/31 11:21:24.953 I4 AddRef(lk)
2012/05/31 11:21:24.953 D7 myClassFactory::AddRef(1)
2012/05/31 11:21:24.953 I4 CoRegisterClassObject() [ok]
2012/05/31 11:21:24.968 I4 AddRef(lk)
2012/05/31 11:21:24.968 D7 myClassFactory::AddRef(2)
2012/05/31 11:21:24.968 D7 myClassFactory::QueryInterface() Ok
2012/05/31 11:21:24.968 I4 AddRef(lk)
2012/05/31 11:21:24.968 D7 myClassFactory::AddRef(3)
2012/05/31 11:21:24.968 D7 myClassFactory::Release(2)
2012/05/31 11:21:24.968 I4 AddRef(lk)
2012/05/31 11:21:24.968 D7 myClassFactory::AddRef(3)
2012/05/31 11:21:24.968 D7 myClassFactory::server_count = 1
2012/05/31 11:21:24.968 D7 myClassFactory::Release(2)
2012/05/31 11:21:25.953 D7 myClassFactory::Release(1)
2012/05/31 11:21:25.953 D7 poll_device ()
2012/05/31 11:21:25.953 D7 Data to tag (6)
2012/05/31 11:21:26.062 D7 loCacheUpdate complete (6)
2012/05/31 11:21:29.062 D7 poll_device ()
2012/05/31 11:21:29.062 D7 poll_device ()
2012/05/31 11:21:29.062 D7 Data to tag (6)
2012/05/31 11:21:29.156 D7 loCacheUpdate complete (6)
Скачать проект