Наш магазин на eBay Наш магазин на AliExpress Наш канал в telegram

Web против SCADA. Часть 2. Простой удалённый мониторинг через web-браузер

В прошлой части я обещал на простеньком примере показать возможность реализации АСУТП при помощи одних только web-технологий, без всяких SCADA-систем. Вот этим мы и займёмся в этой и последующих частях.

Сегодня мы будем решать только задачу примитивного отображения данных в браузере и их динамического обновления, поэтому не будем пока трогать Apache и MySQL (это потом), а в качестве http-сервера будем использовать простейший самопал, написанный на C++ Builder с помощью Indy (как сделать такой http-сервер написано тут).

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

Мы, для примера, будем использовать шлюз UART-to-I2C/SPI/1W (к нему можно прицепить пачку далласовских термометров DS18B20 и у него есть 3 I/O).

Если что — вот готовый проект на C++ Builder, который умеет сканировать шину 1W, мониторить данные с датчиков температуры и управлять ногами i/o (остаётся прикрутить к нему http-сервер и сделать динамическое обновление данных на странице).

Итак, сначала давайте прикрутим к нашему тестовому проекту http-сервер, который будет просто отдавать страницу с таблицей, заполненной значениями считанных с термометров температур и параметрами I/O.

Для этого, создадим в папке с проектом пустой html-документ, в котором будет содержаться только основной каркас (ну, давайте ещё название добавим, для красоты, например, «АСУТП через Web»), и назовём его shablon.html:

<html>
	<header>
		<title>АСУТП через Web</title>
	</header>
	<body>
	</body>
</html>

Далее, откроем наш тестовый проект и добавим на форму компонент Memo (будем через него открывать файл шаблона), сожмём его до размеров иконки и установим в false свойства Visible и WordWrap (чтоб он нам всякие переносы кареток не вставлял).

p class=»redstr»>Добавим глобальную переменную Shablon типа AnsiString и допишем в конструкторе формы (вот здесь: __fastcall TForm1::TForm1(TComponent* Owner) ), перед тем как успешно выйти, следующий кусочек кода (пишем этот код вместо слова return):

if(FileExists("shablon.html"))	// если файл шаблона существует
{	Memo1->Lines->Clear();
	Memo1->Lines->LoadFromFile("shablon.html"); // загружаем его в Memo
	Shablon=Memo1->Text;    // и копируем в переменную Shablon
}
return; // выход из функции, если всё успешно загрузилось

Теперь при запуске приложения (точнее при создании формы), мы получим текст нашего файла-шаблона в строковую переменную Shablon.

Далее, добавим на форму панель с двумя кнопками и полем Edit, а также компонент IdHTTPServer. Панель подпишем «Server options», в поле Edit впишем текст «80» (в этом поле будем выбирать порт, поэтому пусть по умолчанию будет 80), а кнопки подпишем так: «Start server», «Stop server» (соответственно, использовать их будем для запуска и остановки http-сервера). Кроме того, проверим, чтобы у компонента IdHTTPServer свойство active было установлено в false, а свойство ParseParams — в true. Свойство Enable для кнопки «Stop server» нужно установить в false.

добавленные на форму компоненты

В обработчиках событий OnClick делаем следующее: для кнопки «Start server» — установим номер порта для IdHTTPServer, активируем кнопку «Stop server», деактивируем кнопку «Start server» и, наконец, активируем сам сервер; для кнопки «Stop Server» — деактивируем кнопку «Stop server», активируем кнопку «Start server» и деактивируем сервер. Код:

//---------------------------------------------------------------------------
void __fastcall TForm1::Button4Click(TObject *Sender)
{  int TempPort;
 
   try  // пытаемся преобразовать текст из Edit2 в целое число
   {    TempPort=StrToInt(Edit2->Text);
   }
   catch (...)  // если не получилось
   {    ShowMessage("Порт может быть только числом от 0 до 65536");
        return;
   }
   if(TempPort<0 || TempPort>65536) // если получилось, но число неправильное
   {    ShowMessage("Порт может быть только числом от 0 до 65536");
        return;
   }
   else
   {    IdHTTPServer1->DefaultPort=TempPort;    // устанавливаем номер порта
        Button4->Enabled=false;                 // деактивируем "Start server"
        Button7->Enabled=true;                  // активируем "Stop server"
        IdHTTPServer1->Active=true;             // запускаем сервер
   }
 
}
//---------------------------------------------------------------------------
void __fastcall TForm1::Button7Click(TObject *Sender)
{ Button4->Enabled=true;
  Button7->Enabled=false;
  IdHTTPServer1->Active=false;  // выключаем сервер
}
//---------------------------------------------------------------------------

Переходим к компоненту IdHTTPServer. Откроем в Object Inspector вкладку Events, щёлкнем два раза по событию OnCommandGet и напишем обработчик этого события. Причём напишем его таким образом, чтобы при получении Get запроса к корню или к странице index.html, наш http-сервер выдавал страничку шаблона с таблицами параметров, а при любом другом запросе — ошибку. Код обработчика:

int           TempPos;
AnsiString    TempStr;
 
TempStr=Shablon;
if(RequestInfo->Document=="/" || RequestInfo->Document=="/index.html") // если обращаются куда нужно
{     ResponseInfo->ResponseNo=200;           // будем отсылать OK
      TempPos=TempStr.Pos("</body>");         // ищем конец тела шаблона
      if(Dev>0)       // если есть термометры - добавляем табличку с температурами
      {       TempStr.Insert("</table>",TempPos);
              for(int i=0; i<Dev+1; i++)      // количество устройств + шапка
              {       TempStr.Insert("</td></tr>",TempPos);
                      TempStr.Insert(StringGrid1->Cells[3][Dev-i],TempPos);
                      TempStr.Insert("</td><td>",TempPos);
                      TempStr.Insert(StringGrid1->Cells[4][Dev-i],TempPos);
                      TempStr.Insert("<tr><td>",TempPos);
              }
              TempStr.Insert("<table style='border: 1px solid red'>",TempPos);
      }
      // добавляем табличку с I/O
      TempStr.Insert("</table>",TempPos);
      int io_col=3;   // количество io
      for(int i=0; i<io_col+1; i++)  // количество io + шапка
      {       TempStr.Insert("</td></tr>",TempPos);
              TempStr.Insert(StringGrid2->Cells[2][io_col-i],TempPos);
              TempStr.Insert("</td><td>",TempPos);
              TempStr.Insert(StringGrid2->Cells[1][io_col-i],TempPos);
              TempStr.Insert("</td><td>",TempPos);
              TempStr.Insert(StringGrid2->Cells[0][io_col-i],TempPos);
              TempStr.Insert("<tr><td>",TempPos);
      }
      TempStr.Insert("<table style='border: 1px solid blue'>",TempPos);
      // формируем ответ
      ResponseInfo->ContentText=TempStr;
}
else  ResponseInfo->ResponseNo=403;   // будем отсылать Forbidden (ну так, ради шутки)

результаты работы http-сервера

Компилируем, запускаем, тестируем. Результат должен получиться такой, как на картинке справа. Целиком проект (как он выглядит на данной стадии) можно скачать вот здесь.

Прикольно? Да, но не очень. Вот если бы данные на страничке ещё и обновлялись автоматически. А ещё неплохо бы, чтобы не всю страницу перегружать, а получать у сервера только те данные, которые нужно обновлять. Да…

Хм, ну давайте так и сделаем. А как спросить что-то у сервера без перезагрузки всей страницы? Ну так как раз для этих целей и придумали AJAX, так что им мы и воспользуемся. Но сначала давайте разработаем общую концепцию того, как это всё будет работать.

Итак, пусть наша страница будет после загрузки вызывать какую-нибудь функцию, которая через AJAX будет запрашивать на сервере нужные данные (то есть те, которые нужно обновлять). Причём, пусть эти данные запрашиваются через параметры GET запроса (которые дописываются к url), благо, мы уже умеем эти параметры распарсивать (снова читаем статью про создание http-сервера в C++ Builder). Скажем, показания датчиков температур будем запрашивать параметром GetTemp=true, а состояние I/O — параметром GetIO=true.

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

Полученный от сервера ответ будем распарсивать на страничке с помощью javascript (есть в нём такая функция — split, которая позволяет распарсить строку с разделителями) и подменять значения в нужных контейнерах на те, которые получили от сервера при помощи innerHTML. Ах, да, для того, чтобы пользоваться innerHTML придётся сделать на шаблоне контейнеры-заготовки, id-шники которых совпадают с тегами передаваемых данных (чтобы знать в какой контейнер какие данные пихать).

Ну и наконец, нужно же данные постоянно обновлять, а не один раз, так что придётся на страничке создать ещё одну функцию, которая будет периодически вызывать функцию для отправки запросов на сервер, а потом, через определённое время, снова саму себя. А старт этой функции привяжем к событию загрузки тела страницы (событие onLoad для тега <body>).

Концепция есть, переходим к деталям. Для начала перепишем шаблон страницы:

Жми чтобы увидеть код

<!DOCTYPE html>
<html xmlns='http://www.w3.org/1999/xhtml'>
 
<head><title>АСУТП через Web</title>
 
<script type='text/javascript'>
function getXmlHttpRequestObject()	// функция создания нового объекта XMLHttpRequest
{	var x;
	if (window.XMLHttpRequest)	// мы в нормальном браузере?
	{	// если в нормальном (IE7+, Firefox,Chrome, Opera, Safari) - создаём объект
		x=new XMLHttpRequest();	// создаём объект XMLHttpRequest
	}
	else x=false;	// если в ненормальном - болт
	return x;	// возвращаем указатель на созданный объект
}
 
// глобальные переменные
var top_http;		// здесь будем хранить указатель на объект XMLHttpRequest
var status;		// эта переменная нужна чтобы опознать, что ответ получен и обработан
var elements_col;	// количество переменных, которые мы будем запрашивать/получать
 
function handleTop()	// обработчик ответа сервера
{	if(top_http.readyState == 4)	// если запрос выполнен (4 - состояние complete)
	{	if(top_http.status == 200)	// если статус ответа - 200 ( Ok! )
		{	var value = top_http.responseText; // получаем в переменную текст ответа
			var ArrVal=value.split(';');	// разделяем ответ на массив параметров
			var obj_tag;			// здесь будет указатель на контейнер
			var i=0;			// это просто счётчик
			elements_col=ArrVal[0]+6;	// количество элементов, которые нужно
							// обработать: количество датчиков +
							// 6 - для I/O (3 - конфа, 3 - защёлки)
			while(i<elements_col)
			{	// проверяем, есть ли на странице контейнер с нужным id?
				obj_tag=document.getElementById(ArrVal[2*i+1]);
				if(obj_tag)					// если есть
				{	// меняем текст внутри него на принятый от сервера
					obj_tag.innerHTML = ArrVal[2*i+2];
				}
				i=i+1;	// проверяем следующий элемент
			}
			status=1;	// меняем статус
		}
	}
}
 
function sendTop()	// В этой функции мы формируем запрос и отправляем его на сервер
{	status=0;	// сбрасываем статус
	top_http = getXmlHttpRequestObject();	// вызываем функцию создания объекта XMLHttpRequest
	top_http.onreadystatechange = handleTop;// цепляем на onreadystatechange обработчик ответа
	var url='?GetTemp=true&GetIO=true';	// строка запроса
 
	top_http.open('GET', url, true);	// настраиваем асинхронный запрос с адресом url
						// (это url от корня того сервера,
						// с которого загружена страница)
	top_http.setRequestHeader('If-Modified-Since','0');	// добавляем заголовки
	top_http.setRequestHeader('Cache-Control','no-cache');	
	top_http.send(null);			// отправляем пустой запрос (поскольку у GET запроса
						// нет тела, всё, что нужно, - запихано в url)
}
 
function getTop()
{	sendTop();				// вызываем функцию sendTop
	window.setTimeout('getTop()',1000);	// вызываем сами себя с задержкой 1000 мс
}
</script>
</head>
<body onLoad='getTop();'> <!-- после загрузки вызываем функцию getTop -->
	<table style="border: 1px solid blue">
		<tr><td>Tag</td><td>Configuration</td><td>Value</td></tr>
		<tr><td>io0</td><td id="io0con"></td><td id="io0val"></td></tr>
		<tr><td>io1</td><td id="io1con"></td><td id="io1val"></td></tr>
		<tr><td>io2</td><td id="io2con"></td><td id="io2val"></td></tr>
	</table>
	<table>
	</table></body>
</html>

[свернуть]

Теперь откроем наш проект в C++ Builder и перепишем код обработчика события OnCommandGet:

AnsiString    TempStr;
// если обращаются к корню или к странице index.html
if(RequestInfo->Document=="/" || RequestInfo->Document=="/index.html")
{ ResponseInfo->ResponseNo=200;		// будем отсылать OK
  if(RequestInfo->Params->Count==0)	// если нет параметров - отправить страничку целиком
  { TempStr=Shablon;
  }
  else	// если параметры есть - начинаем их обрабатывать и формировать строку данных ответа
  { TempStr=IntToStr(Dev); // сначала количество датчиков
    for(int i=0; i<RequestInfo->Params->Count; i++) // просматриваем по очереди все параметры
    { if(RequestInfo->Params->Names[i]=="GetTemp") // если среди параметров есть запрос температуры
      { if(Dev>0)	// если есть датчики
        { for(int j=0; j<Dev; j++) // добавляем теги и значения через ;
          { TempStr=TempStr+";"+StringGrid1->Cells[4][j+1]+";"+StringGrid1->Cells[3][j+1];
          }
        }
      }
      if(RequestInfo->Params->Names[i]=="GetIO") // если среди параметров есть запрос IO
      { for(int j=0; j<3; j++) // добавляем теги и значения через ;
        { // конфигурация
          TempStr=TempStr+";"+StringGrid2->Cells[0][j+1]+"con;"+StringGrid2->Cells[1][j+1];
          // значение
          TempStr=TempStr+";"+StringGrid2->Cells[0][j+1]+"val;"+StringGrid2->Cells[2][j+1];
        }
      }
    }
  }
  // формируем ответ
  ResponseInfo->ContentText=TempStr;
}
else  ResponseInfo->ResponseNo=403;   // будем отсылать Forbidden (ну так, ради шутки)

Почти всё, как видите код обработчика даже стал ещё меньше, поскольку теперь не нужно динамически вставлять на страницу разметку для таблицы IO. Однако, мы ведь всё равно не знаем сколько у нас будет датчиков, поэтому таблицу для датчиков всё равно придётся создавать динамически. Но сделаем мы это в той функции, которая отвечает за поиск устройств на шине. Кроме того, как видите, данные к контейнеру у нас привязываются по id контейнера, а id совпадает с именем тега. Это удобно, но в этом случае у нас не должно быть данных без тегов, поэтому каждому найденному датчику мы будем автоматически присваивать тег (потом его можно изменить).

Итак, в обработчике события OnClick, для кнопки, которая у нас подписана «Scan 1W Bus», в том месте, где мы редактируем табличку на форме под новый найденный девайс (после строки StringGrid1->Cells[1][Dev]=TempStr;), — дописываем такой кусок кода:

// автоматически присваиваем тег
StringGrid1->Cells[4][Dev]="TE"+IntToStr(Dev);
// добавляем ячейки в html-шаблон
int TempPos=Shablon.Pos("</table></body>"); // ищем таблицу для датчиков
// и вписываем в неё ячейки для нового датчика
Shablon.Insert("<tr><td>TE"+IntToStr(Dev)+"</td><td id=TE"+IntToStr(Dev)+"></td></tr>",TempPos);

Осталось только изменить кусок кода, в котором мы по своему желанию переименовываем теги, иначе после изменения тега в программе, он не появится на html-страничке (хотя страничку всё равно придётся после изменения имени тега перегружать). В самом начале обработчика события OnClick для кнопки, подтверждающей переименование тега (в нашем тестовом проекте это Button5) нужно дописать такой код:

результаты работы http-сервера после внесённых изменений

AnsiString TempStr;
int TempDl, TempPos;
TempStr=StringGrid1->Cells[4][ARowClick]; // получаем старое имя тэга
TempDl=TempStr.Length(); // вычисляем его длину
TempPos=Shablon.Pos(TempStr); // ищем его на странице шаблона
Shablon.Delete(TempPos,TempDl); // удаляем его
Shablon.Insert(Edit1->Text,TempPos); // и вставляем новое имя

Вот и всё. Проект в том виде, как он должен выглядеть после всех внесённых нами изменений, можно скачать вот здесь.

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

Красота конечно, но мониторинг через web — это только половина дела. Неплохо бы ещё и удалённо управлять нашим устройством (переключать выходы, менять состояние защёлок…) Что ж, вот этим мы и займёмся в следующий раз 🙂

  1. Часть 1. Противостояние неизбежно, результат — предсказуем.
  2. Часть 2. Простой удалённый мониторинг через web-браузер.
  3. Часть 3. Удалённое управление через web-браузер.
  4. Часть 4. Продвинутая визуализация в web-браузере. АСУТП аквариума.

Добавить комментарий