Я работаю инженером АСУТП и немного увлекаюсь программированием: при помощи Гугла и Stack Overflow делал несколько калькуляторов на HTML и javascript, делал бота для телеграма на php, даже немного программировал на c# по работе. В этот раз задача была куда интереснее и сложнее, хотя и звучала просто: «хочу видеть в своем браузере текущую скорость агрегата». Для начала я решил попробовать поискать готовый софт: естественно такое уже давно придумано, есть готовые и даже бесплатные SCADA системы, которые могут работать и в качестве веб сервера, но они все были сильно наворочены и сложны для моего понимания, к тому же нужно было просто вывести скорость. Поэтому я подумал что можно попробовать сделать это самому и вот что из этого вышло:
Backend
После того как я решил что буду делать сам, снова открыл поисковик и стал искать как самому сделать свой OPC клиент.
Поиски этого привели меня на хабр, где я узнал про бесплатную библиотеку OPCDOTNET. В архиве библиотеки лежал исходник консольного клиента, который я скомпилировал на своем компьютере, запустил простой OPC симулятор (gray-box)… и о чудо! я увидел в консоли изменяющиеся числа. Это значит, что теперь я смогу их отправлять в качестве ответа по вебзапросу. Следующим заходом в гугл стал запрос простого веб сервера где наткнулся на пример использования HttpListener. Запустил пример в отдельном проекте, понял как это работает, и стал добавлять все это к своему OPC клиенту. Через много попыток компиляций, поиска ошибок на Stack Overflow у меня все же получилось увидеть в браузере заветную «скорость». Это была победа! Но я сразу же понял, что одна лишь скорость это не серьезно, через время технологи захотят увидеть и другие параметры линии, поэтому нужно придумать как добавлять необходимые сигналы, без изменения программы. На помощь пришли файлы конфигурации, где можно заранее задать какие сигналы хотим видеть, задать порт прослушивания сервера, время обновления и прочее. Опыт в создании файлов конфигурации уже имелся, поэтому сделал так как ранее делал и проверенно работало. Так же в процессе пришлось обратиться к другу программисту, который подсказал что сделать чтобы передавался полный массив запрашиваемых данных, а не только те значения что менялись (в готовом примере OPC клиента в консоли отображались только изменяемые значения).
После таких изменений программа стала генерировать таблицу в HTML из запрашиваемых в конфиге сигналов: обратившись через браузер по адресу сервера, где был запущен этот клиент, теперь можно было видеть таблицу, в которой были названия сигналов и значения в соседнем столбце. Это было уже неплохо, но значения при обновлении промаргивали, а сами сигналы тупо располагались друг за другом хоть и были структурированы в виде таблицы. Кстати, чтобы значения обновлялись автоматически ежесекундно, а не только когда пользователь обновит страницу, я добавил в возвращаемую на запрос страницу тег meta с параметром Refresh. Но мне очень хотелось чтобы значения обновлялись автоматически и без перезагрузки страницы, поэтому нужно было кроме бэкенда теперь делать и фронт: пользователь запрашивает страницу на сервере, внутри которой происходит запрос к клиенту, и страница после этого генерирует все это в красивом и понятном виде, где можно структурировать данные как заблагорассудится, поменять цвета, шрифты и размеры — сделать можно вообще все что угодно при таком-то подходе.
Frontend
Пришел я к этому не сразу: сначала стал гуглить как сделать так чтобы данные на странице обновлялись без перезагрузки. Как выяснилось нужно использовать AJAX, то есть изменять данные через javascript, а принимать их через JSON. В клиенте простой конкатенацией строк сделал генерацию JSON, причем для универсальности решил просто отсчитывать по порядку задаваемые в конфиге теги. Потом нашел пример в котором через javascript запрашивается ежесекундно JSON строка и выводятся значения из нее. Поменяв код под свои нужды и запустив страницу я увидел что все работает — данные обновляются без перезагрузки страницы (!). Это была еще одна победа. Теперь оставалось дело за малым — грамотно распределить на странице полученные данные, то есть сделать что-то в виде визуализации. Сначала я решил сделать так же таблицу, но потом понял что блочная структура смотрится красивее и функциональнее. Блоки можно окрашивать в разные цвета и менять их размер. А еще нужно сделать так чтобы пользователь мог самостоятельно добавлять и изменять структуру, не буду же я на каждую новую хотелку переписывать HTML файл. В итоге получился такой вот вариант, как на картинке ниже.
Здесь можно добавлять большие блоки, которые будут объединять малые блоки с одним признаком. Такие большие блоки можно озаглавливать так как нужно, менять их цвета (если щелкнуть по блоку с зажатой клавишей shift) и менять их размер. Блоки со значениями добавляются при двойном клике по большому блоку. В них так же можно задавать свои названия и единицы измерения. Если нечаянно добавил не тот элемент или не туда, то можно удалить его — я подсмотрел эту функцию в одном букмарклете, полностью перенеся его код на страницу. Конечно вся созданная структура после перезагрузки страницы исчезнет и для ее сохранения нашел такую возможность как локальное хранилище. А для того чтобы перенести готовую структуру на другой компьютер сделал импорт и экспорт экрана из локального хранилища.
Единственная проблема оставалась с перетаскиванием блоков — хотелось бы сделать красиво drag and drop, но для меня это оказалось непосильно. Вышел из ситуации так: если открыть страницу в панели разработчика в хроме, то блоки можно перетаскивать. Это натолкнуло на мысль что задействовав правую кнопку мыши можно просто менять блоки местами. Сейчас такая система вполне универсальная: чтобы добавить новый сигнал нужно просто добавить нужный OPC тег в конфиг и перезапустить клиента. Добавленный тег автоматически добавляется в JSON и на экране вывода появляется внизу новое значение, которое можно несколькими кликами добавить в существующий или новый блок на странице. На данный момент на странице выводится больше 60 тегов и больше половины из них добавлял уже не я, то есть процесс добавления может и не самый простой, но не требует переписывания программы и страницы вывода. Протестировать и посмотреть код этой страницы можно тут
Заключение
Поскольку данная статья должна быть вроде инструкции, как непрограммист вроде меня с помощью поисковиков может сделать что-то полезное, то наверное нужно добавить немного слов о том как именно я искал информацию. Тут впору говорить как на картинке в самом начале: думаешь что ты хочешь получить и спрашиваешь об этому у гугла, а если что-то где-то не получается, то смотришь на коды ошибок и спрашиваешь снова. Очень помогает поиск на английском языке — даже вбив просто ключевые слова можно получить ссылку на подобную решенную проблему на стаковерфлоу с вероятностью 80%. Для поиска готовых примеров, код из которого можно тупо взять и перенести в свою программу, можно добавлять такие ключевые слова как «example» или по-русски «пример». Несколько хороших идей нашлось на хабре, то есть можно попробовать в запрос вставить ключевое слово «habr», но я таким пользовался только тогда когда точно знал что на хабре видел решение которое ищу. Практически любая мелкая задача из всего того, что было сделано, решалась через поисковик: «change div color shift click js», «make div resizeable», «как редактировать веб страницу»… сотня вариаций разных запросов. Возможно в комментариях профи могут поделиться своими советами.
И да, раз уж речь зашла о советах, то мне бы еще хотелось получить от вас конструктивную критику и полезные советы. Возможно кто-то захочет размять
Ссылка на код клиента на C#
/*=====================================================================
File: OPCCSharp.cs
Summary: OPC sample client for C#
-----------------------------------------------------------------------
This file is part of the Viscom OPC Code Samples.
Copyright(c) 2001 Viscom (www.viscomvisual.com) All rights reserved.
THIS CODE AND INFORMATION ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A
PARTICULAR PURPOSE.
======================================================================*/
using System;
using System.Threading;
using System.Runtime.InteropServices;
using System.Configuration;
using OPC.Common;
using OPC.Data;
using System.Net;
using System.Globalization;
using System.Data.SqlClient;
using System.Data;
using System.Net.Sockets;
namespace CSSample
{
class Tester
{
// *********************************************************** EDIT THIS :
string serverProgID = ConfigurationManager.AppSettings["opcID"]; // ProgID of OPC server
private OpcServer theSrv;
private OpcGroup theGrp;
private static float[] currentValues;
private static string responseStringG ="";
private static HttpListener listener = new HttpListener();
private static string consoleOut = ConfigurationManager.AppSettings["consoleOutput"];
private static string answerType = ConfigurationManager.AppSettings["answerType"];
private static string portNumb = ConfigurationManager.AppSettings["portNumber"];
private static int timeref = Int32.Parse(ConfigurationManager.AppSettings["refreshTime"]);
private static string[] tagsNames = ConfigurationManager.AppSettings["tagsNames"].Split(','); // tags from config
private static string[] ratios = ConfigurationManager.AppSettings["ratios"].Split(',');
private static string sqlSend = ConfigurationManager.AppSettings["sqlSend"];
private static string udpSend = ConfigurationManager.AppSettings["udpSend"];
private static string webSend = ConfigurationManager.AppSettings["webSend"];
private static string table_name = ConfigurationManager.AppSettings["table"]; // название таблицы из конфига;
private static string column_name = ConfigurationManager.AppSettings["column"];
private static int sendtags = Int32.Parse(ConfigurationManager.AppSettings["tags2send"]);
private static IPAddress remoteIPAddress = IPAddress.Parse(ConfigurationManager.AppSettings["remoteIP"]); // Ip from config
private static int remotePort = Convert.ToInt16(ConfigurationManager.AppSettings["remotePort"]); // remote port from config
public static SqlConnection myConn = new SqlConnection(ConfigurationManager.ConnectionStrings["connstr"].ConnectionString); //строка соединения с SQL которая берется из конфига
SqlCommand myCommand = new SqlCommand("Command String", myConn);
public void Work()
{
/* try // disabled for debugging
{ */
theSrv = new OpcServer();
theSrv.Connect(serverProgID);
Thread.Sleep(500); // we are faster then some servers!
// add our only working group
theGrp = theSrv.AddGroup("OPCCSharp-Group", false, timeref);
string[] tags = ConfigurationManager.AppSettings["tags"].Split(','); // tags from config
if (sendtags > tags.Length) sendtags = tags.Length;
var itemDefs = new OPCItemDef[tags.Length];
for (var i = 0; i < tags.Length; i++)
{
itemDefs[i] = new OPCItemDef(tags[i], true, i, VarEnum.VT_EMPTY);
}
OPCItemResult[] rItm;
theGrp.AddItems(itemDefs, out rItm);
if (rItm == null)
return;
if (HRESULTS.Failed(rItm[0].Error) || HRESULTS.Failed(rItm[1].Error))
{
Console.WriteLine("OPC Tester: AddItems - some failed"); theGrp.Remove(true); theSrv.Disconnect(); return;
};
var handlesSrv = new int[itemDefs.Length];
for (var i = 0; i < itemDefs.Length; i++)
{
handlesSrv[i] = rItm[i].HandleServer;
}
currentValues = new Single[itemDefs.Length];
// asynch read our two items
theGrp.SetEnable(true);
theGrp.Active = true;
theGrp.DataChanged += new DataChangeEventHandler(this.theGrp_DataChange);
theGrp.ReadCompleted += new ReadCompleteEventHandler(this.theGrp_ReadComplete);
int CancelID;
int[] aE;
theGrp.Read(handlesSrv, 55667788, out CancelID, out aE);
// some delay for asynch read-complete callback (simplification)
Thread.Sleep(500);
while (webSend=="yes")
{
HttpListenerContext context = listener.GetContext();
HttpListenerRequest request = context.Request;
HttpListenerResponse response = context.Response;
context.Response.AddHeader("Access-Control-Allow-Origin", "*");
byte[] buffer = System.Text.Encoding.UTF8.GetBytes(responseStringG);
// Get a response stream and write the response to it.
response.ContentLength64 = buffer.Length;
System.IO.Stream output = response.OutputStream;
output.Write(buffer, 0, buffer.Length);
// You must close the output stream.
output.Close();
}
// disconnect and close
Console.WriteLine("************************************** hit <return> to close...");
Console.ReadLine();
theGrp.ReadCompleted -= new ReadCompleteEventHandler(this.theGrp_ReadComplete);
theGrp.RemoveItems(handlesSrv, out aE);
theGrp.Remove(false);
theSrv.Disconnect();
theGrp = null;
theSrv = null;
/* }
catch( Exception e )
{
Console.WriteLine( "EXCEPTION : OPC Tester " + e.ToString() );
return;
} */
}
// ------------------------------ events -----------------------------
public void theGrp_DataChange(object sender, DataChangeEventArgs e)
{
foreach (OPCItemState s in e.sts)
{
if (HRESULTS.Succeeded(s.Error))
{
if (consoleOut == "yes")
{
Console.WriteLine(" ih={0} v={1} q={2} t={3}", s.HandleClient, s.DataValue, s.Quality, s.TimeStamp); // выводит данные при изменении в консоль
}
currentValues[s.HandleClient] = Convert.ToSingle(s.DataValue) * Single.Parse(ratios[s.HandleClient], CultureInfo.InvariantCulture.NumberFormat); //добавляет в массив измененное значение тега
}
else
Console.WriteLine(" ih={0} ERROR=0x{1:x} !", s.HandleClient, s.Error);
}
string responseString = "{";
if (answerType == "table")
{
responseString = "<HTML><head><meta charset="UTF-8"><meta http-equiv="Refresh" content="" + timeref / 1000 + ""/></head>" +
"<BODY><table border><tr><td>" + string.Join("<br>", tagsNames) + "</td><td >" + string.Join("<br>", currentValues) + "</td></tr></table></BODY></HTML>";
responseStringG = responseString;
}
else
{
for (int i = 0; i < currentValues.Length - 1; i++) responseString = responseString + ""tag" + i + "":"" + currentValues[i] + "", ";
responseString = responseString + ""tag" + (currentValues.Length - 1) + "":"" + currentValues[currentValues.Length - 1] + ""}";
responseStringG = responseString;
}
byte[] byteArray = new byte[sendtags * 4];
Buffer.BlockCopy(currentValues, 0, byteArray, 0, byteArray.Length);
if (sqlSend == "yes")
{
try
{
SqlCommand cmd = new SqlCommand("INSERT INTO " + table_name + " (" + column_name + ") values (@bindata)", myConn);
myConn.Open();
var param = new SqlParameter("@bindata", SqlDbType.Binary)
{ Value = byteArray };
cmd.Parameters.Add(param);
cmd.ExecuteNonQuery();
myConn.Close();
}
catch (Exception err)
{
Console.WriteLine("SQL-exception: " + err.ToString());
return;
}
}
if (udpSend == "yes") UDPsend(byteArray);
}
private static void UDPsend(byte[] datagram)
{
// Создаем UdpClient
UdpClient sender = new UdpClient();
// Создаем endPoint по информации об удаленном хосте
IPEndPoint endPoint = new IPEndPoint(remoteIPAddress, remotePort);
try
{
sender.Send(datagram, datagram.Length, endPoint);
//Console.WriteLine("Sended", datagram);
}
catch (Exception ex)
{
Console.WriteLine("Возникло исключение: " + ex.ToString() + "n " + ex.Message);
}
finally
{
// Закрыть соединение
sender.Close();
}
}
public void theGrp_ReadComplete(object sender, ReadCompleteEventArgs e)
{
Console.WriteLine("ReadComplete event: gh={0} id={1} me={2} mq={3}", e.groupHandleClient, e.transactionID, e.masterError, e.masterQuality);
foreach (OPCItemState s in e.sts)
{
if (HRESULTS.Succeeded(s.Error))
{
Console.WriteLine(" ih={0} v={1} q={2} t={3}", s.HandleClient, s.DataValue, s.Quality, s.TimeStamp);
}
else
Console.WriteLine(" ih={0} ERROR=0x{1:x} !", s.HandleClient, s.Error);
}
}
static void Main(string[] args)
{
string url = "http://*";
string port = portNumb;
string prefix = String.Format("{0}:{1}/", url, port);
listener.Prefixes.Add(prefix);
listener.Start();
Tester tst = new Tester();
tst.Work();
}
}
}
/* add this code to app.exe.config file
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.1" />
</startup>
<appSettings>
<add key="opcID" value="Graybox.Simulator" />
<add key="tagsNames" value="Line Speed,Any name,Любое имя" />
<add key="tags" value="numeric.sin.int16,numeric.sin.int16,numeric.sin.int16" />
<!-- ratios for tags -->
<add key="ratios" value="1,0.5,0.1" />
<add key="portNumber" value="45455" />
<add key="refreshTime" value="1000" />
<!-- "yes" or no to show values in console-->
<add key="consoleOutput" value="yes" />
<add key="webSend" value="no" />
<!-- "table" or json (actually any other word for json)-->
<add key="answerType" value="json" />
<add key="sqlSend" value="no" />
<add key="table" value="raw_tbl" />
<add key="column" value="data" />
<add key="udpSend" value="yes" />
<add key="remotePort" value="3310"/>
<add key="remoteIP" value="127.0.0.1"/>
<add key="tags2send" value="2" />
</appSettings>
<connectionStrings>
<add connectionString="Password=12345;Persist Security Info=True;User ID=user12345;Initial Catalog=amt;Data Source=W7-VS2017" name="connstr" />
</connectionStrings>
</configuration>
*/
Автор: Alexey