python

ScadaPy сервер JSON

  • вторник, 28 ноября 2017 г. в 03:12:57
https://habrahabr.ru/post/343302/
  • SCADA
  • Python


В продолжение предыдущих статей о применении python для построения собственной scada системы, хотелось бы описать способ организации обмена между устройствами и вывод данных посредством json — текстового формата обмена данными.

В данном случае будем использовать клиентские части modbusTCP и OPCUA библиотек.
В итоге у нас получится http сервер, работающий в качестве master для подчиненных устройств, которые в свою очередь работают в режиме slave.

image

Modbus master


Для настройки работы мастера modbus TCP, импортируем необходимые библиотеки:

import modbus_tk
import modbus_tk.defines as cst
import modbus_tk.modbus_tcp as modbus_tcp

Необходимо выполнить инициализацию мастера с указанием ip адреса и порта, а также timeout ожидания ответа:

master = modbus_tcp.TcpMaster(host=’127.0.0.1’, port=502)
master.set_timeout(2)

Описываем циклическую функцию опроса slave устройств, с указанием названий регистров и адресов ячеек:

def getModbus():
  while True:
      try:
        data= master.execute(rtu, cst.READ_INPUT_REGISTERS,0,1 )
      except Exception as e:
         print (e) 
      time.sleep(1)

#rtu – адрес RTU modbus
# cst.READ_INPUT_REGISTERS – название регистра, в режиме чтения их может быть четыре:
#cst.READ_INPUT_REGISTERS
#cst.READ_DISCRETE_INPUTS
#cst.READ_COILS
#cst.READ_HOLDING_REGISTERS 

Теперь нужно запустить цикл опроса в отдельный поток thread:

modb = threading.Thread(target=getModbus)
modb.daemon = True
modb.start()

В результате будет запущен циклический опрос подчиненного устройства по протоколу modbusTCP с IP адресом 127.0.0.1 и портом 502. Читаться будет регистр READ_INPUT_REGISTERS и в переменную data будет записано значение находящееся по адресу 0х00.

OPCUA клиент


Для получения данных от OPCUA сервера, необходимо подключить библиотеку freeopcua

 from opcua import ua, Client
и создать новое клиентское подключение:

url="opc.tcp://127.0.0.1:4840/server/"
try:
         client = Client(url)
         client.connect()
         root = client.get_root_node()
except Exception as e:
         print(e)

В OPC серверах жесткая иерархия наследования, существует точное определение parent и child, поэтому можно строить довольно сложные системы с большим количеством вложенных объектов. Но нам, в данном случае, такое количество функций на сегодняшний день не понадобилось, поэтому мы ограничились созданием узла в корневой папке Objects и присвоением ему значения. Получилось приблизительно так Objects -> MyNode -> MyNodeValue, но надо признаться, что для построения более сложных систем этот способ не приемлем.

obj = root.get_child(["0:Objects"])
objChild= obj.get_children()
for i in range(0,len(objChild)):
          unitsChild.append(i)
          unitsChild[i]=objChild[i].get_children()
          parName=val_to_string(objChild[i].get_browse_name())[2:]
          for a in range(0, len( unitsChild[i] ) ):
                 valName=val_to_string(unitsChild[i][a].get_browse_name())[2:]
                  try:
                         valData=unitsChild[i][a].get_value()
                         data =unitsChild[i][a].get_data_value()
                         st=val_to_string(data.StatusCode)
                         ts= data.ServerTimestamp.isoformat()
                         tsc= data.SourceTimestamp.isoformat()
                   except Exception as e:
                         print(e)

Непосредственно значение переменной можно увидеть в valData, в st записывается StatusCode, ts и tsc записываются временные метки ServerTimestamp и SourceTimestamp соответственно.

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

Web сервер Json


Для создания web сервера потребуются библиотеки:

from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import base64
Сам сервер запустить несложно, всего две команды, в сети существует большое количество описаний и примеров.

server_address = (“127.0.0.1”, 8080)
httpd = server_class(server_address, handler_class)
try:
          httpd.serve_forever()
except Exception as e:
         print(e)
         httpd.server_close()

Самое интересное началось позже, когда для тестирования возникла необходимость подключиться из браузера Chrome или Firefox к созданному серверу.

Постоянно выскакивал refuse_connect.

Немного поискав в сети, нашли решение – нужно в функцию do_GET добавить:

self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Credentials', 'true')

Теперь удалось получить доступ к работающему web серверу, но с открытым доступом, а хотелось бы установить какую-нибудь авторизацию, доступ по логину и паролю.
Как оказалось это не особо сложно сделать используя headers.

Пример
def do_GET(self):
         global key

         if self.headers.get('Authorization') == None:
             self.do_AUTHHEAD()
             response = { 'success': False, 'error': 'No auth header received'}
             self.wfile.write(bytes(json.dumps(response), 'utf-8'))


         elif self.headers.get('Authorization') == 'Basic ' + str(key):
             resp=[]
             self.send_response(200)
             self.send_header('Allow', 'GET, OPTIONS')
             self.send_header("Cache-Control", "no-cache")
             self.send_header('Content-type','application/json')
             self.send_header('Access-Control-Allow-Origin', 'null')
             self.send_header('Access-Control-Allow-Credentials', 'true')
             self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
             self.send_header('Access-Control-Allow-Headers', 'X-Request, X-Requested-With')
             self.send_header("Access-Control-Allow-Headers", "Authorization")
             self.end_headers()
             req=str(self.path)[1:]
             if(req == "all" ):
                 try:
                     for i in range(0,units):
                         resp.append({varName[i]:[reg[i],varNameData[i]]})
                         i+=1
                     self.wfile.write(json.dumps( resp ).encode())

                 except Exception as e:
                     print('all',e)
             else:
                 for i in range(0,units):
                     if(req == varName[i] ):
                         try:
                             resp =json.dumps({ varName[i]:varNameData[i] }  )
                             self.wfile.write(resp.encode())
                         except Exception as e:
                             print(e)
                     i+=1
         else:
             self.do_AUTHHEAD()
             response = { 'success': False, 'error': 'Invalid credentials'}
             self.wfile.write(bytes(json.dumps(response), 'utf-8'))


Если теперь попробовать подключиться посредством браузера, то авторизация выполняется и данные передаются, но получать данные из браузера без парсера не самая хорошая идея, мы предполагали получать данные методом GET с помощью JavaScrypt и функции XMLHttpRequest(), используя сценарий в странице html. Но при такой реализации браузер сначала отправляет запрос не методом GET, а методом OPTIONS и должен получить response = 200, только после этого будет выполнен запрос методом GET.

Добавили еще функцию:

def do_OPTIONS(self):
         self.send_response(200)
         self.send_header('Access-Control-Allow-Credentials', 'true')
         self.send_header('Access-Control-Allow-Origin', 'null')
         self.send_header('Access-Control-Allow-Methods', 'GET,OPTIONS')
         self.send_header('Access-Control-Allow-Headers', 'X-Request, X-Requested-With')
         self.send_header("Access-Control-Allow-Headers", "origin, Authorization, accept")
         self.send_header('Content-type','application/json')
         self.end_headers()

При подключении этой функции проверка будет осуществляться по 'Access-Control-Allow-Origin' и, если его не поставить равным 'null', обмена не будет.

Теперь мы имеем доступ по логину и паролю, браузер будет обмениваться данными согласно сценарию, но желательно организовать шифрование данных SSL. Для этого необходимо сформировать файл сертификата SSL и перед запуском сервера добавить строку:

httpd.socket = ssl.wrap_socket (httpd.socket, certfile=pathFolder+'json_server.pem',ssl_version=ssl.PROTOCOL_TLSv1, server_side=True)  

Конечно это самоподписной сертификат, но в любом случае это лучше чем открытый протокол.

Для обработки данных в сценарии на html странице, можно использовать вышеупомянутую функцию XMLHttpRequest():

Пример
xmlhttp=new XMLHttpRequest();                                   
xmlhttp.open("GET","http://192.168.0.103:8080/all",true);          
xmlhttp.setRequestHeader("Authorization", "Basic " + btoa(login+":"+password));
xmlhttp.withCredentials = true;
xmlhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xmlhttp.send(null);


          xmlhttp.onreadystatechange=function()           
                 {
                       if (xmlhttp.readyState==4 && xmlhttp.status==200)
                      {
                        resp= xmlhttp.responseText;
                        parseResp=JSON.parse(resp);
                       }
                  }


Описание конфигуратора JSON


Ниже приводится примерное описание настройки конфигуратора для запуска скриптов.

Внешний вид окна и назначение кнопок управления:

image

Допустим, стоит задача получать данные от датчика температуры с параметрами:
Протокол: modbusTCP
IP адрес: 192.168.0.103
Порт: 502
RTU: 1
Регистр: READ_INPUT_REGISTERS (0x04)
Адрес: 0
Имя переменной: tempSensor_1
Вывести эти данные на json сервере:
Формат: json
IP адрес: 192.168.0.103
Порт: 8080
Логин: 111
Пароль: 222

Запускаем json.py, добавляем новый сервер кнопка (+) слева вверху, указываем название и сохраняем.

Теперь, нужно оформить созданный instance и ввести параметры web сервера.

image

Записываем параметры опроса подчиненного устройства, в данном случае датчика температуры:

image

После этого, при нажатии кнопки сохранить скрипт, в папке scr появится файл с названием web_(номер нашего сервера в базе).bat для Windows или web_(номер нашего сервера в базе).sh для Linux. В этом файле будут прописаны пути запуска скрипта.

В данном случае пример для Windows, файл web_15.bat:

rem Скрипт создан в программе 'ScadaPy Web JSON Сервер v.3.14'
rem Сервер Web 'Сервер датчика температуры'
rem Http адрес '192.168.0.103'
rem Http порт '8080'
start c:\Python35\python.exe F:\scadapy\main\source\websrv.py 15 F:\scadapy\main\db\webDb.db

Можно запустить скрипт сразу на выполнение, нажав кнопку расположенную рядом с кнопкой сохранения (все кнопки снабжены всплывающими подсказками).

После запуска появится консольное окно с информацией о запуске и подключениях.

image

Теперь, запустив браузер, пишем строку подключения _https://192.168.0.103:8080/all, а после ввода пароля видим следующее в Chrome:

image

Или в Firefox:

image

А в консоли запущенного сервера будет выведена информация о сессиях подключения:

image

В данном случае мы получаем данные по всем переменным настроенным на сервере, поскольку в запросе GET ввели параметр all. Это не совсем правильно, поскольку при увеличении количества переменных придется получать и обрабатывать данные, которые в текущий момент не используются, поэтому лучше вводить непосредственное имя переменной, значение которой необходимо обработать: tempSensor_1.

В данном случае:
Запрос — tempSensor_1
Ответ — {«tempSensor_1»: [2384]}

Обработка в JavaScript


Хочется немного описать, каким образом встроить формирование запроса и обработку ответа в html страницу.

Для выполнения запроса можно воспользоваться функцией XMLHttpRequest(), хотя в настоящее время существуют и другие способы подключения. При успешном подключении и получения статуса равного 200, достаточно выполнить функцию JSON.parse().
Чтобы установить цикличность выполнения запросов необходимо запустить таймер.

Пример.
  function getTemp() 
{
         var dataReq='tempSensor_1';
         var login='111', passw='222';
         var ip='192.168.0.103';
         var port='8080';

         if (window.XMLHttpRequest) {  xmlhttp=new XMLHttpRequest();                        }
         else                       {  xmlhttp=new ActiveXObject("Microsoft.XMLHTTP");      }

                  xmlhttp.open("GET","https://"+ip+":"+port+"/"+dataReq,true);          
                  xmlhttp.setRequestHeader("Authorization", "Basic " + btoa(login+":"+passw));
                  
                  xmlhttp.withCredentials = true;
                  xmlhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
        xmlhttp.send(null);


         xmlhttp.onreadystatechange=function()

        {
               if (xmlhttp.readyState==4 && xmlhttp.status==200)
             {
             
              resp= xmlhttp.responseText;
              parseResp=JSON.parse(resp);
              data=parseResp.tempSensor_1[0];
              
              log("Val :" + data +"\n");
              resp=data*0.1;
             
              }
        }
 }

Пример отображения полученных данных в различных виджетах.

image

При получении данных от OPCUA сервера, структура JSON ответа немного изменится, но незначительно. В любом случае разобраться там не составит труда.

Ссылка для скачивания на github