В продолжение предыдущих статей о применении python для построения собственной scada системы, хотелось бы описать способ организации обмена между устройствами и вывод данных посредством json — текстового формата обмена данными.
В данном случае будем использовать клиентские части modbusTCP и OPCUA библиотек.
В итоге у нас получится http сервер, работающий в качестве master для подчиненных устройств, которые в свою очередь работают в режиме slave.
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
Ниже приводится примерное описание настройки конфигуратора для запуска скриптов.
Внешний вид окна и назначение кнопок управления:
Допустим, стоит задача получать данные от датчика температуры с параметрами:
Протокол: 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 сервера.
Записываем параметры опроса подчиненного устройства, в данном случае датчика температуры:
После этого, при нажатии кнопки сохранить скрипт, в папке 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:Python35python.exe F:scadapymainsourcewebsrv.py 15 F:scadapymaindbwebDb.db
Можно запустить скрипт сразу на выполнение, нажав кнопку расположенную рядом с кнопкой сохранения (все кнопки снабжены всплывающими подсказками).
После запуска появится консольное окно с информацией о запуске и подключениях.
Теперь, запустив браузер, пишем строку подключения _https://192.168.0.103:8080/all, а после ввода пароля видим следующее в Chrome:
Или в Firefox:
А в консоли запущенного сервера будет выведена информация о сессиях подключения:
В данном случае мы получаем данные по всем переменным настроенным на сервере, поскольку в запросе 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;
}
}
}
Пример отображения полученных данных в различных виджетах.
При получении данных от OPCUA сервера, структура JSON ответа немного изменится, но незначительно. В любом случае разобраться там не составит труда.
Ссылка для скачивания на github
Автор: jackmas