День добрый, хочу поведать вам как я сделал опрос счетчика ChintPD7777 8s по rs-485 и собирал статистику с выводом её же на Вебморду.
Предисловие: на сайте производителя есть руководство пользователя в котором подробно описаны регистры для опроса. НО нигде не указана нормальная инструкция как с этим работать. К тому же я приложу файлы для поднятия Web сервера и просмотра истории измерений.
-
Подключаемся
На самом счетчике есть клемы 58 и 59 к которым мы цепляемся. Для приема сигнала я буду использовать MOXA 5130 или аналог не суть важно. Важно что я использую RTU OVER TCP.
Нажимаем menu -> просит пароль (по умолчанию 701) -> conn
prot - абсолютно мне непонятные символы скрываются под этим меню, я оставил по умолчанию, а именно n.2
bAud - скорость передачи, тут опять таки в инструкции не слова, а внутри меню просто цифры от 1 до 5, методом проб я выбрал 3, что соответствует 9600
addr - адрес slave устройства, по умолчанию 1
-
Пытаемся считать
Разные "переходники" rs485 to ethernet , имея разный функционал не всегда показывают подключено что то в принципе по rs 485 или нет. Так что будем работать наверняка. Для начала в Web-конфигураторе "переходника" устанавливаем параметры ранее выбранные на счетчике. На моем устройстве которое называется DieWU это выглядит так:
Качаем ModBusPOOL и пытаемся подключиться к нашему "переходнику".
При успехе окошко должно выглядеть так:
Нажав правой кнопкой мыши можно выбрать регистры которые мы хотим опрашивать, я думаю, больше всего интересуют параметры начинающиеся с 2000H. В инструкции они регистры представлены в 16-ричном формате без последний буквы "H". То есть частота в инструкции подписана как 2044H, переводим в 10-чную любым онлайн-калькулятором - получаем регистр 2060. "H" значит что мы должны запрашивать параметры через функцию 04 Read Input Registers (3x), что нисколько не мешает нам использовать Read Holding Registers.
3. Поднимаем мониторинг
Поднимать мониторинг я решил на python + mysql. Используем библиотеку "pymodbus".
import asyncio
from pymodbus.client import ModbusTcpClient
from pymodbus import (
FramerType,
ModbusException,
pymodbus_apply_logging_config,
)
import struct
import mysql.connector
# Настройки подключения
host = '192.168.8.254' # IP-адрес устройства
port = 23 # Порт для RTU over TCP (по умолчанию)
db_host = 'localhost'
db_user = 'bduser'
db_password = 'bdpassword'
db_name = 'bdname'
# Создание клиента
async def read_and_store_registers():
while True:
client = ModbusTcpClient(host, port=port, framer=FramerType.RTU,retries=100)
# Подключение к устройству
if client.connect():
# Список адресов регистров, которые нужно прочитать
register_addresses = [8192]
for address in register_addresses:
# Чтение значения одного регистра, count это количество битов, slave - адрес контроллера
response = client.read_holding_registers(address=address, count=70, slave=1)
if response.isError():
print(f"Ошибка при чтении регистров {address}.")
else:
value8192 = struct.unpack('>f', struct.pack('>HH', response.registers[0], response.registers[1]))[0]
value8194 = struct.unpack('>f', struct.pack('>HH', response.registers[2], response.registers[3]))[0]
value8196 = struct.unpack('>f', struct.pack('>HH', response.registers[4], response.registers[5]))[0]
value8198 = struct.unpack('>f', struct.pack('>HH', response.registers[6], response.registers[7]))[0]
value8200 = struct.unpack('>f', struct.pack('>HH', response.registers[8], response.registers[9]))[0]
value8202 = struct.unpack('>f', struct.pack('>HH', response.registers[10], response.registers[11]))[0]
value8204 = struct.unpack('>f', struct.pack('>HH', response.registers[12], response.registers[12]))[0]
value8206 = struct.unpack('>f', struct.pack('>HH', response.registers[14], response.registers[13]))[0]
value8208 = struct.unpack('>f', struct.pack('>HH', response.registers[16], response.registers[15]))[0]
value8212 = struct.unpack('>f', struct.pack('>HH', response.registers[18], response.registers[17]))[0]
value8214 = struct.unpack('>f', struct.pack('>HH', response.registers[22], response.registers[21]))[0]
value8216 = struct.unpack('>f', struct.pack('>HH', response.registers[24], response.registers[23]))[0]
value8220 = struct.unpack('>f', struct.pack('>HH', response.registers[28], response.registers[27]))[0]
value8222 = struct.unpack('>f', struct.pack('>HH', response.registers[30], response.registers[29]))[0]
value8224 = struct.unpack('>f', struct.pack('>HH', response.registers[32], response.registers[31]))[0]
value8248 = struct.unpack('>f', struct.pack('>HH', response.registers[56], response.registers[55]))[0]
value8250 = struct.unpack('>f', struct.pack('>HH', response.registers[58], response.registers[57]))[0]
value8252 = struct.unpack('>f', struct.pack('>HH', response.registers[60], response.registers[59]))[0]
value8254 = struct.unpack('>f', struct.pack('>HH', response.registers[62], response.registers[61]))[0]
value8256 = struct.unpack('>f', struct.pack('>HH', response.registers[64], response.registers[63]))[0]
value8258 = struct.unpack('>f', struct.pack('>HH', response.registers[66], response.registers[65]))[0]
value8260 = struct.unpack('>f', struct.pack('>HH', response.registers[68], response.registers[67]))[0]
# Вывод результата
if value8192 is not None:
conn = mysql.connector.connect(
host=db_host,
user=db_user,
password=db_password,
database=db_name
)
cursor = conn.cursor()
table_name = f"{address}" # Замените на имя вашей таблицы
column_name = "val" # Замените на имя вашего столбца
data_tuple = (value8260)
# Вставка значения в таблицу
insert_query = f"INSERT INTO `8192` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8192}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8194` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8194}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8196` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8196}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8198` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8198}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8200` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8200}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8202` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8202}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8204` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8204}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8206` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8206}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8208` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8208}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8212` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8212}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8214` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8214}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8216` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8216}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8220` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8220}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8222` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8222}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8224` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8224}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8248` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8248}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8250` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8250}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8252` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8252}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8254` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8254}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8256` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8256}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8258` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8258}, NULL);"
cursor.execute(insert_query, data_tuple)
insert_query = f"INSERT INTO `8260` (`id`, `time`, `val`, `comm`) VALUES (NULL, current_timestamp(), {value8260}, NULL);"
cursor.execute(insert_query, data_tuple)
conn.commit()
cursor.close()
# Закрытие подключения
client.close()
else:
print("Не удалось установить соединение с устройством.")
# Ждем 0.5 секунду перед следующей итерацией
await asyncio.sleep(0.5)
# Запуск асинхронного события
asyncio.run(read_and_store_registers())
Почему так сложнои «индусообразно», а не стал циклом опрашивать только нужные регистры? Изначально я так и подумал и сделал первый вариант который из массива нужных мне регистров запрашивает показатели. Но после суточного теста с периодом опроса 0,5 сек( что бы видеть резкие скачки на производстве ), запросы по rs-485 не успевали обрабатываться и показания предыдущего регистра падали в следующий. В примере который привел я такого не происходит. Так как мы берем 70 регистров в массив и точечно вытаскиваем по 2 регистра с нужными нам данными.
4. Вывод графиков на «Вебморду»
Дальше хотелось вывести графики с показателями по каждому регистру за определенный промежуток времени.
index.php
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Указываем тип документа и язык -->
<meta charset="UTF-8">
<!-- Устанавливаем кодировку страницы в UTF-8 -->
<title>Form Post Request</title>
<!-- Задаем заголовок для страницы -->
<style>
body {
font-family: Arial, sans-serif;
/* Устанавливаем шрифт для всего документа */
background-color: #f4f4f9;
/* Устанавливаем фоновый цвет страницы */
margin: 0;
padding: 20px;
/* Убираем внешние отступы и добавляем внутренние */
}
h1 {
text-align: center;
color: #333;
/* Центрируем заголовок и задаем цвет */
}
form {
max-width: 600px;
margin: 0 auto;
background-color: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
/* Устанавливаем форму с максимальной шириной и центрируем ее */
}
label {
display: block;
margin-bottom: 5px;
color: #666;
/* Отображаем метку как блок, добавляем отступы и задаем цвет */
}
input[type="datetime-local"], select {
width: 100%;
padding: 8px;
margin-bottom: 20px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
/* Устанавливаем стили для полей ввода и селектов */
}
button {
background-color: #28a745;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
/* Устанавливаем стили для кнопок */
}
button:hover {
background-color: #218838;
/* Изменяем цвет при наведении на кнопку */
}
</style>
</head>
<body>
<h1>ТП 5 ЭНЕРГОЦЕНТР</h1>
<!-- Заголовок страницы -->
<form action="/index_f.php" method="get">
<!-- Форма с методом GET, отправляющая данные на указанный URL -->
<label for="option">Option:</label>
<select name="option" id="option">
<option value='8192'>Межфазное наряжение AB</option>
<option value='8194'>Межфазное наряжение BC</option>
<option value='8196'>Межфазное наряжение CA</option>
<option value='8198'>Напряжение ФАЗА А</option>
<option value='8200'>Напряжение ФАЗА B</option>
<option value='8202'>Напряжение ФАЗА C</option>
<option value='8204'>Ток фаза А</option>
<option value='8206'>Ток фаза B</option>
<option value='8208'>Ток фаза C</option>
<option value='8212'>Активная мощность фазы А</option>
<option value='8214'>Активная мощность фазы B</option>
<option value='8216'>Активная мощность фазы C</option>
<option value='8220'>Реактивная мощность фазы А</option>
<option value='8222'>Реактивная мощность фазы В</option>
<option value='8224'>Реактивная мощность фазы С</option>
<option value='8248'>Процент гармоники напряжения фазы A</option>
<option value='8250'>Процент гармоники напряжения фазы B</option>
<option value='8252'>Процент гармоники напряжения фазы С</option>
<option value='8254'>Процент гармоники тока фазы А</option>
<option value='8256'>Процент гармоники тока фазы В</option>
<option value='8258'>Процент гармоники тока фазы C</option>
<option value='8260'>Частота</option>
<option value='8262'>Мониторинг перекоса</option>
</select><br><br>
<label for="from">От (Дата и Время):</label>
<input type="datetime-local" name="from" id="from"><br><br>
<!-- Поле ввода для даты и времени начала -->
<label for="to">До (Дата и Время):</label>
<input type="datetime-local" name="to" id="to"><br><br>
<!-- Поле ввода для даты и времени окончания -->
<button type="submit">Отправить Запрос</button>
</form>
</body>
</html>
Тут должно быть все понятно кроме одного - откуда взялся перекос фаз и опция 8262 если её нет в официально документации. Это уже авторская задумка по выводу показателей 3-ёх фаз на одном графике в одинаковые моменты времени.
Запрос обрабатывает index_f.php
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Указываем тип документа и язык -->
<meta charset="UTF-8">
<!-- Устанавливаем кодировку страницы в UTF-8 -->
<title>MySQL Data with Chart.js</title>
<!-- Задаем заголовок для страницы -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- Подключаем библиотеку Chart.js из CDN -->
<script type="text/javascript" charset="utf-8">
document.addEventListener('DOMContentLoaded', function() { // Добавляем обработчик события загрузки страницы
var option = new URLSearchParams(window.location.search).get("option"); // Получаем параметр 'option' из строки запроса
var from = new URLSearchParams(window.location.search).get("from"); // Получаем параметр 'from' из строки запроса
var to = new URLSearchParams(window.location.search).get("to"); // Получаем параметр 'to' из строки запроса
if (option === null || from === null || to === null) { // Проверяем, что все параметры переданы
console.log('Missing parameters');
return; // Если хотя бы один параметр отсутствует, завершаем выполнение скрипта
}
var kef = (option === '8262') ? 3 : 10; // Устанавливаем коэффициент в зависимости от значения параметра 'option'
fetch('fetch_data.php', { // Отправляем запрос на сервер для получения данных
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded' // Указываем тип контента запроса
},
body: `option=${encodeURIComponent(option)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}` // Создаем тело запроса с параметрами
})
.then(response => response.json()) // Преобразуем ответ сервера в JSON
.then(data => { // Обрабатываем полученные данные
console.log(data); // Выводим данные в консоль для отладки
var ctx = document.getElementById('myChart').getContext('2d'); // Получаем контекст элемента canvas с ID 'myChart'
var myChart; // Создаем переменную для хранения экземпляра Chart.js
if (option === '8262') { // Если параметр 'option' равен '8262'
const valuesData1 = data.data1.map(item => item.val / 3); // Преобразуем значения данных для фазы A
const valuesData2 = data.data2.map(item => item.val / 3); // Преобразуем значения данных для фазы B
const valuesData3 = data.data3.map(item => item.val / 3); // Преобразуем значения данных для фазы C
const generatedLabels = data.data1.map(item => item.time); // Получаем метки времени
myChart = new Chart(ctx, { // Создаем новый экземпляр Chart.js
type: 'line',
data: {
labels: generatedLabels,
datasets: [
{
label: 'Фаза A',
data: valuesData1,
backgroundColor: 'rgba(75, 192, 192, 0.2)',
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 1,
pointStyle: false,
tension: 0.1
},
{
label: 'Фаза B',
data: valuesData2,
backgroundColor: 'rgba(54, 162, 235, 0.2)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1,
pointStyle: false,
tension: 0.1
},
{
label: 'Фаза C',
data: valuesData3,
backgroundColor: 'rgba(255, 99, 132, 0.2)',
borderColor: 'rgba(255, 99, 132, 1)',
borderWidth: 1,
pointStyle: false,
tension: 0.1
}
]
},
options: {
scales: {
y: {
beginAtZero: true
}
}
}
});
} else { // Если параметр 'option' не равен '8262'
var labels = data.map(item => item.time); // Получаем метки времени
var values = data.map(item => item.val); // Получаем значения данных
var values10 = values.map(number => number / kef); // Преобразуем значения данных с учетом коэффициента
myChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
pointStyle: false,
data: values10,
backgroundColor: 'rgba(255, 99, 132, 0.2)',
borderColor: 'rgba(255, 99, 132, 1)',
borderWidth: 1
}]
},
options: {
scales: {
y: {
beginAtZero: true
}
}
}
});
}
// Обновляем заголовок страницы
document.getElementById('title').textContent = `Данные для параметра ${option}`;
})
.catch(error => { // Обрабатываем ошибки запроса
console.error('Error:', error);
});
});
</script>
</head>
<body>
<h1 id="title">Заголовок страницы</h1> <!-- Заголовок страницы, который будет обновляться в зависимости от параметра 'option' -->
<canvas id="myChart"></canvas> <!-- Элемент canvas для отображения графика -->
</body>
</html>
Для вывода используется Chart.js. Прошу обратить внимание на 26-ую строку, на переменную «kef». Так как chint отдает нам данные в » Одинарная точность с плавающей запятой» и » Все данные мощности, считываемые функцией передачи данных представляют собой вторичную величину (энергия исключена, коэффициент исключен) » нам требуется применять эти коэффициенты вручную. Так как, например, частоту 50 он возвращает как «5000.0», а коэффициенты трансформаторов (если такие имеются) у всех разные. Выводить их отдельно из регистров не стал, так как, замена трансформаторов дело не частое. Ну и «колхозинг» с 8262 — перекосом фаз. Элегантнее сделать не получилось.
Данные мы берем из MySQL базы (потому что мне просто привычно с ней работать), файл подключения fletch_data.php
<?php
$option = $_POST['option'] ?? '';
$from = $_POST['from'] ?? '';
$to = $_POST['to'] ?? '';
if (empty($option) || empty($from) || empty($to)) {
die("Не все параметры переданы");
}
$host = 'localhost';
$dbname = 'bdname';
$username = 'bduser';
$password = 'bdpassword';
try {
$pdo = new PDO("mysql:host=$host;dbname=$dbname", $username, $password);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
if ($option == '8262') {
// Подготовленный запрос с использованием параметров
$sql1 = "SELECT * FROM `8204` WHERE `time` BETWEEN :from AND :to ORDER BY id DESC";
$stmt1 = $pdo->prepare($sql1);
$sql2 = "SELECT * FROM `8206` WHERE `time` BETWEEN :from AND :to ORDER BY id DESC";
$stmt2 = $pdo->prepare($sql2);
$sql3 = "SELECT * FROM `8208` WHERE `time` BETWEEN :from AND :to ORDER BY id DESC";
$stmt3 = $pdo->prepare($sql3);
// Привязка значений к параметрам
$stmt1->bindParam(':from', $from, PDO::PARAM_STR);
$stmt1->bindParam(':to', $to, PDO::PARAM_STR);
$stmt2->bindParam(':from', $from, PDO::PARAM_STR);
$stmt2->bindParam(':to', $to, PDO::PARAM_STR);
$stmt3->bindParam(':from', $from, PDO::PARAM_STR);
$stmt3->bindParam(':to', $to, PDO::PARAM_STR);
$stmt1->execute();
$stmt2->execute();
$stmt3->execute();
$data1 = $stmt1->fetchAll(PDO::FETCH_ASSOC);
$data2 = $stmt2->fetchAll(PDO::FETCH_ASSOC);
$data3 = $stmt3->fetchAll(PDO::FETCH_ASSOC);
$response = [
'data1' => $data1,
'data2' => $data2,
'data3' => $data3
];
header('Content-Type: application/json');
echo json_encode($response);
} else {
// Подготовленный запрос с использованием параметров
$sql = "SELECT * FROM `$option` WHERE `time` BETWEEN :from AND :to ORDER BY id DESC";
$stmt = $pdo->prepare($sql);
// Привязка значений к параметрам
$stmt->bindParam(':from', $from, PDO::PARAM_STR);
$stmt->bindParam(':to', $to, PDO::PARAM_STR);
$stmt->execute();
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode($data);
}
} catch (PDOException $e) {
die("Ошибка подключения к базе данных: " . $e->getMessage());
}
?>
Естественно предварительно вам требуется создать базу данных в которой будет таблицы с именами регистров.В моем случае это выглядит так.

С такой структурой таблиц:

Если все сделано правильно, python скрипт после запуска будет каждые 0,5 сек опрашивать chint и заносить показатели в таблицы.
Вид index.php

Вид index_f.php

P.S. Сразу оговорюсь я не АСУшник и не электрик, даже не энергетик. Основная профессия Системный администратор. Прошу сильно не ругать, выложил сей опус только из за того, что не нашел нормального мануала и какой либо документации к этим устройствам в принципе(кроме адресации регистров). Вдруг кому пригодится.
XitroUtko
prot - абсолютно мне непонятные символы скрываются под этим меню, я оставил по умолчанию, а именно n.2 - это параметры порта связи , n2 значит parity none , 2 stop bit.
bAud - скорость передачи, тут опять таки в инструкции не слова, а внутри меню просто цифры от 1 до 5, методом проб я выбрал 3, что соответствует 9600 - аналогично, настройка скорости modbus , которая стандартизована. Для счетчиков обычно они бывают 2400,4200,9600,19200.
У вас на скрине настроек преобразователя все эти параметры как раз и указаны, только стоповый бит вместо 2 стоит 1.
Буква H в номере регистра указывает на то, что данный адрес - в hex, а не на функцию чтения.
И очень интересно, что при одиночном запросе счетчик не успевал отдавать данные, а при запросе 70 регистров - все ок..
fastheel Автор
У меня почему то работает с 1 стоп битом при настройке n.2
"Обычно" понятие растяжимое - в мануалах на 3 языках которые я нашёл не единого упоминания соответствий цифр и скоростей.
По поводу ошибок обмена - работает уже неделю. В первом варианте с массивом регистров - были ошибки. Не собираюсь доказывать правоту или правильность используемых методов. Я просто писал опыт своего взаимодействия с данным аппаратом.
belav
С таких слов начинаются приключения в продаете...