Для кого
— Для тех кто использует OpenSCADA, но не может реализовать больше чем решения «из коробки»
— Для тех кто ищет СКАДу для себя, но так и не может определится
— Для тех кто забросил этот проект, так и не разобравшись как он работает

Зачем
— Данное решение позволяет считывать показания счетчиков меркурий 230 и меркурий 200 без каких либо лимитов
— Это бесплатно


Проекту openscada (oscada.org) уделяют не заслужено мало внимания, о нем написана всего одна статья на хабре. Большинство инженеров боятся трогать и трехметровой палкой этот продукт, черт его знает какой этот ваш линукс. Разрабатывает его уже не первый десяток лет фактически один человек, Роман Савоченко.

Не имея раньше опыта со СКАДА вообще (а с линуксом немного дружил) выбрал именно его для реализации мониторинга объектов на предприятии. Так как сравнить мне было не с чем, интерфейс и все связи данных с друг другом я воспринял как должное. Очень помог видеоурок «быстрый старт», лично я считаю таких уроков можно было сделать и побольше. Документацию тоже пришлось перечитывать не раз, но оно того стоило. Подключив первый модуль сбора данных Невод+ долго не мог понять почему он не работает. Ведь как совместимый с протоколом DCON он в списке проекта числился(точнее его аналог). Полез в исходник протокола и… оказалось что совсем он с ним не совместим, как и многие другие модули сбора из списка. Первое обращение на форум проблему мою исправило и еще несколько ошибок довольно оперативно. Рассказывать обо всех тонкостях системы я не буду, лучше прочтите вышеупомянутую статью на хабре или посмотрите «быстрый старт».

Спустя какое то время мне понадобилось снимать показания с электросчетчиков Меркурий 230. Поддержки этих счетчиков в openscada нет. Попробовал утилиту taskgroup от создателя всем известного konfiguratorа, опрашивать счетчики по CSD ей оказалось дохлым номером. Но все не так плохо как могло быть, openscada система предельно модульная и написать свой модуль можно хоть на С++, хоть на языке высокого уровня прямо в ней. Описание протокола обмена для меркурия 230 без проблем можно найти в сети, производитель «Инкотекс» конечно может предоставить вам описание по запросу, но мне не хотелось связываться с этой волокитой.

Итак, подключаем шину со счетчиками, для наглядности и лучшей ориентации в протоколе ставим konfigurator и сниффер последовательно порта, открываем документацию. Пытаемся прочитать данные со счетчика с адресом 75.

все скриншоты кликабельны



Видим как побежали наши данные.



Протокол обмена для меркурий 230 очень похож на протокол modbus.

Запрос на открытие канала связи предназначен для разрешения доступа к данным с указанием уровня доступа. В счетчике реализован двухуровневый доступ к данным: первый (низший) — уровень потребителя, и второй (высший) — уровень хозяина



Попытаемся с помощью конфигуратора опросить наш счетчик и видим что первый запрос это и есть пароль, а ответ счетчика это 4 байта. включающие в себя



Теперь попытаемся это реализовать на openscada. В С++ я не силен, поэтому решил реализовать на языке, встроенном в саму СКАДу, который там зовется JavaLikeCalc.Javascript. Сам код опроса реализуется в двух модулях UserProtocol и DevLib. Создадим устройство в библиотеке устройств и назовем m230. Добавим атрибуты netaddr(сетевой адрес), password(пароль), transport(последовательный порт) и answer(ответ на запрос пароля). И напишем запрос.



Теперь перейдем к протокольной части и создадим в UserProtocol наш пользовательский протокол и назовем его так же m230. Начнем с преобразования сетевого адреса. Код расчета контрольной суммы modbus CRC16 уже был написан давно, мне осталось его только вставить в свой код.



Создадим и транспорт, прописав в нем нужный порт, скорость и тайминги.



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



Не лишним будет и включить архивацию в соответствующей вкладке.



Переходим ко вкладке Атрибуты и видим наши 4 байта ответа от счетчика. Пароль принят, отлично!!!



Что же попробуем считать показания электроэнергии. Добавляем в в атрибуты шаблона несколько записей еще несколько строк кода для каждого тарифа и для их суммы.



Далее добавим в наш протокол еще строки. Не лишнем будет проверить ответ на тот ли запрос пришел и проверить длину пакета. Каждый 4 байта полезной информации ответа интерпретируется своей последовательностью байт, для чтения энергии она видна на скриншоте. В конце из 16ричной системы данные переводим в десятичную, к тому же это число надо разделить на 1000.



Заходим опять в конфигурацию шаблона, ставим галку «Считывать энергию от сброса» и в атрибутах у нас уже видны данные о тарифах.



На этом останавливаться мы не собираемся и попробуем добавить мгновенные данные — напряжение, ток и мощность. Здесь все тоже самое, меняем только второй, третий и четвертый байт, которые отвечают у нас за то, какую информацию мы хотим получить от счетчика.





Немного изменений добавим и на стороне протокола.Проверяем ответ на байты из чего строим предположение о его длине и проверяем ее, добавляем свою последовательность байт, переводим в десятичную систему и делим на 100 для ответа о напряжении и мощности и на 1000 для ответа о токе.



Теперь в атрибутах нашего счетчика видим все его основные данные, которых конечно в разы больше и при желании можно добавить еще, например частоту в герцах и многое другое.



Добавим для наглядности еще несколько счетчиков. Но это не все, данные надо не просто считывать но и представить их в удобном виде. Для этого в openscada существует Vision (рабочий пользовательский интерфейс) в котором данные можно представить в любом удобном для вас виде, хоть в виде мнемосхемы, в виде графиков, в виде документов итд. Возьмем стандартный документ из шаблона и отредактируем его чтобы получилось так.



А в обработку документа добавим строку, чтобы можно было легко читать архивы данных по дням.



В итоге запускаем проект и открываем наш документ.



Если нужно представить мгновенные значения или из архива то создаем график, добавляя туда наши значения. Вот пример значений для счетчика по напряжению.



Но спустя некоторое время не отпускала идея написать заодно и протокол для однофазных счетчиков меркурий 200. Описание протокола я в сети не нашел, но мир не без добрых людей.

Сетевой адрес тут и есть пароль счетчика. По умолчанию он равен последним 6 цифрам серийного номера. Попробуем написать шаблон.

Вот схема пакета запроса и ответа



Серийный номер счетчика слишком длинный чтоб уместить его в 32-битное целое число, поэтому поделим его на две части.

Код запроса тарифа 0x27, пишем структуру запроса и выделяем какие байты за какой тариф у нас отвечают. И делим это значение на 100. И проверяем наш ответ на объем символов.

Чтобы считывать мгновенные значения используем код запроса 0х63. Также проверим наш ответ на количество байтов. Нюансы по каждому из этих значений тоже учитываем.

Но что делать если счетчик закодирован программой наладчик+? К счастью как кодирует наладчик+ всем уже давно известно, поэтому добавляем строку в начало нашего кода.



Перейдем к протокольной стороне. Преобразовываем наш адрес в шестнадцатеричную систему. Расчет контрольной суммы и запрос как и в предыдущем протоколе.



Добавим несколько счетчиков и в конфигурации шаблона пропишем наши настройки.



И во вкладке Атрибуты видим как счетчик отдает нужные нам значения.



Создадим документ чтобы просматривать эти значения в более удобном виде. Отредактируем наш шаблон документа. Запустим наш проект.



Все оказалось совсем несложно. Данный протокол можно скачать на форуме oscada.org/ru/forum в разделе «Разработка OpenSCADA». И на данный момент, насколько мне известно это единственное бесплатное решение для меркуриев на неограниченное количество счетчиков.

P.S. Написал я это дело еще 3 года назад, только недавно решил этим поделится.
P.P.S. В статье скорей всего есть неточности, которыми Роман явно был бы недоволен.
Поделиться с друзьями
-->

Комментарии (30)


  1. igor_suhorukov
    16.11.2016 16:45

    Спасибо за полезную информацию! Счетчики массовые и так подключать и считывать данные с них правда легче


  1. Wedmer
    16.11.2016 17:49

    Руководство для тех, кто не вкуривает официальную вики проекта.
    Но сам факт статьи радует.


  1. fenst
    16.11.2016 19:51

    … либо можно купить RS-232 <-> LAN шлюз и использовать облачный сервис яЭнергетик. Он там чего-то стоит, но какие-то десятки рублей в месяц, кажется.


    1. iegova
      16.11.2016 19:59

      в меркуриях нет RS-232, там CAN и RS-485


      1. fenst
        16.11.2016 22:32

        несущественно ;)
        image


  1. cerberus13
    16.11.2016 20:02

    Могу попросить Романа, чтобы прочитал статью — может вышлет свои замечания :-)


    1. iegova
      16.11.2016 20:03

      Критики от Романа мне и на форуме хватало, хотя конечно пусть прочтет, почему бы и нет.


      1. cerberus13
        16.11.2016 22:24

        Да Роман нетерпим к другим зачастую :-)
        Хотя и «голова», что есть, то есть.


  1. Krivohizhin
    16.11.2016 20:57

    Не плохо!
    Тоже подумываю к своему счетчику подключится, только у меня Микрон, но протокол похож.


  1. fido_max
    17.11.2016 07:37

    Статью, как писал модуль сбора данных для Fastwel IO, писать?


    1. iegova
      17.11.2016 08:26

      Я бы почитал


    1. PavelVainerman
      17.11.2016 11:05

      Конечно. Было бы интересно.


    1. Wedmer
      18.11.2016 01:49

      Опязательно пишите) Сравню ваш опыт со своим)


    1. s60
      23.11.2016 10:07

      конечно писать…


  1. alz72
    17.11.2016 08:07

    Я правильно понял что на Меркуриях линукс крутиться?


    1. iegova
      17.11.2016 08:25
      +1

      Нет, на линуксе СКАДА крутится, которая и собирает данные со счетчика. А что крутится в самом меркурии понятия не имею.


  1. alid
    17.11.2016 08:22

    Вовсе это не единственное бесплатное решение. Вот, например здесь

    За статью спасибо.


  1. viras777
    17.11.2016 12:34

    У нас есть своя система, в частности в ней одна из задач как раз энергоучёт. Закупать или ставить стороннее приложение не хотели по внутренним причинам, поэтому реализовали на php протокол опроса Меркуриев 230/234. В итоге около 60 счётчиков подключены группами по 7-10шт к преобразователям в Ethernet (Moxa) и на FreeBSD скрипт раз в 5 минут снимает актуальные показания или, если был пропущен какой-то часовой интервал, считывает их из памяти Меркурия. Если интересно, поделюсь кодом.


    1. iegova
      17.11.2016 12:53

      В php я не шарю, но думаю другим пригодится, было бы неплохо написать об этом статью и где то выложить код.


  1. viras777
    17.11.2016 13:40

    Код php использует официальный протокол Меркуриев и идеи от дядьки, код которого очень помог разобраться в протоколе. В моём примере пароль админа в счётчике заменён на стандартный. По умолчанию адрес счётчика — это последние 2 или 3 цифры в серийном номере. Каждая moxa и её порт опрашиваются параллельно. Запрашиваем по одному все счётчики, висящие на одном порту moxa.
    Алгоритм (каждые 5 минут часа):
    — Читаем серийный номер счётчика, для контроля;
    — Читаем онлайн данные по фазам: силу тока, мощность, напряжение, косинус фи;
    — Пишем в БД;
    — Если первые 4 минуты любого часа дня, то считываем общее потребление за прошедший час;
    — Если между 5-ю и 10-ю минутами 0-го часа, то проверяем все ли показания есть за предыдущий период в БД. Если что-то отсутствует, то считываем показания из памяти счётчика;

    P.S. То, что данные читаются онлайн, не из памяти счётчиков и могут браться не точно с 0-минут до 0-минут следующего часа, а с дельтой в несколько секунд не является принципиальным моментом, т.к. эта ж дельта будет и в следующем часу.

    Собственно сам код
    #!/usr/local/bin/php
    <?php 
    /**
     * \file Считываем показания со счётчиков
     */
    include_once realpath(dirname($_SERVER['SCRIPT_FILENAME'])).'/general.php';
    include_once ABSOLUTE_CLASS.'db.php';
    
    class eCounters {
        // дескриптор открытого канала на моху
        private $fp = 0;
    
        // 0 - слать на email И ЗАПИСЫВАТЬ в базу. 1(потом пид) - отладка на экран и НЕ записывать в базу.
        public $debug = 0;
        public $db = true;
    
        // Строка ошибки
        public $errMsg = '';
    
        // Параметры соединения
        private $ip, $port, $dev;
    
        // Параметры merc_gd
        private $t3byte = 1;
        private $t4byte = 2;
        private $tSerial = 3;
        private $tNormal = 4;
    
        // Версия прошивки
        private $ver;
        
        // http://ab-log.ru/smart-house/mercury-230
        function CRC_Modbus($val='') {
            $Data=hex2bin($val);
            $len=strlen($Data);
            $Sum=0xFFFF;
            $cou = 0;
            while ($len--){
                $Sum^= ord($Data[$cou]);
                for ($shift_cnt=0; $shift_cnt<8; $shift_cnt++) {
                    if (($Sum&0x1)==1) $Sum=(($Sum>>1)^0xA001);
                    else $Sum>>=1;
                }
                $cou++;
            }
            $Sum=dechex($Sum);
            $len=4-strlen($Sum);
            while ($len--){
                $Sum='0'.$Sum;
            }
            return $val.$Sum[2].$Sum[3].$Sum[0].$Sum[1];
        }
    
        // Добавляет 0 к началу числа до $lim длинны
        function zeropad($num, $lim) {
            return (strlen($num) >= $lim) ? $num : $this->zeropad('0'.$num, $lim);
        }
    
        // Переводим бинарные динные в текстовое 16-тиричное представление
        function dd($data = '') {
            $result = '';
            $data2 = '';
            for ( $j = 0; $j < count($data); $j++ ) {
                $data2 = dechex(ord($data[$j]));
                if ( strlen($data2) == 1  )
                    $result = '0'.$data2;
                else
                    $result .= $data2;
            }
    
            return $result;
        }
    
        function _hexdec($hex_string) {
            $result=hexdec($hex_string);
    
            if ( $result <= 9 )
                return '0'.$result;
            else
                return $result;
        }
    
        /*
         * Отправляет на счётчик команду, читает и декодирует ответ
         * $cmd - команда а отправку
         * $resp_len - ожидаемая длинна ответа
         * $factor - коэффициент, на который надо умножить результат, т.к. результат чтения из потока только целочисленный
         * $total - тип ответа, в зависимости от этого отдаётся разное представление данных
         * $recurse_in - сам себя вызывает в случае неудачной попытки работы с потоком
         */
        function merc_gd($cmd, $resp_len, $factor = 1, $total = 0, $recurse_in = 0) {
    
            if($this->debug >= 3)
                echo $this->dev.': cmd:'.$cmd."\n";
    
            if($total == 0)
                $total = $this->t3byte;
                
            // 3 попытки на выполнение команды
            if($recurse_in)
                $retry=0;
            else
                $retry=3;
            do {
                flush();
                fwrite($this->fp, hex2bin($cmd));
                // 6 попыток прочитать ответ
                $read_retry=6;
                $result='';
                do {
                    $read_retry--;
                    if (false === ($result .= stream_get_contents($this->fp)))
                        $read_retry=0;
                } while (strlen($result)!=$resp_len && $read_retry );
                if ( !$read_retry || (bin2hex($result) != $this->CRC_Modbus(substr(bin2hex($result),0,strlen(bin2hex($result))-4)))) {
                    $this->close_connection();
                    if(!$retry--) {
                        // Почтой, что счётчик недоступен
                        if ($this->debug >= 1)
                            echo $this->dev.': Moxa error: Cant recive data from: '.$this->ip.':'.$this->port.' dev:'.$this->dev."\n";
                        else
                            $this->errMsg .= 'Cant recive data from: '.$this->ip.':'.$this->port.' dev:'.$this->dev."\r\n<br>";
                        return false;
                    }
                    if (false === $this->init_connection())
                        return false;
                }
                else
                    break;
            } while (1);
        
            $ret = array();
            $start_byte = 1;
    	
            if ( $total == $this->t3byte )
            {
                // 3-х байтовый ответ по текущим показателям
                for ( $i = 0; $i < 4; $i++ )
                {
                    $mask=63; // 3Fh - надо убрать 2 старших бита, это направление энергии
                    $sign_mask=128; // 80h - направление течения
                    if ( (ord($result[$start_byte + $i * 3]) & $sign_mask) > 0 )
                        $sign=-1;
                    else
                        $sign=1;
                    if ( strlen($result) > $start_byte + 2 + $i * 3 )
                        $ret[$i] = hexdec($this->dd($result[$start_byte + $i * 3] & $mask).
                                        $this->dd($result[$start_byte + $i * 3 + 2]).
                                        $this->dd($result[$start_byte + $i * 3 + 1]))*$factor*$sign;
                }
            }
            elseif ( $total == $this->t4byte )
                // 4-х байтовый ответ
                $ret[0] = hexdec($this->dd($result[$start_byte+1]).
                                $this->dd($result[$start_byte]).
                                $this->dd($result[$start_byte+3]).
                                $this->dd($result[$start_byte+2]))*$factor;
            elseif ( $total == $this->tSerial )
                // Тут запрос серийника
                $ret[0] = $this->_hexdec($this->dd($result[$start_byte])).
                        $this->_hexdec($this->dd($result[$start_byte+1])).
                        $this->_hexdec($this->dd($result[$start_byte+2])).
                        $this->_hexdec($this->dd($result[$start_byte+3]));
            elseif ( $total == $this->tNormal )
                // Просто отдать строку
                $ret[0] = bin2hex($result);
    
            if($this->debug >= 3)
                echo $this->dev.': ret:'.$ret[0]."\n";
            return $ret;
        }
    
        function init_connection()
        {
            $retry=3;
            do
            {
                $retry--;
                sleep(1);
            } while ( $retry && (false === ($this->fp = fsockopen($this->ip, $this->port, $errno, $errstr, 10))) );
            if (! $retry)
            {
                //Попробуем перезапустить моху
                if (false === file_get_contents('http://'.$this->ip.'/09Set.htm?Submit=Submit'))
                {
                    // Почтой, что моха недоступна
                    if ($this->debug >= 1)
                        echo $this->dev.': Moxa unavailable: Can not connect to: '.$this->ip."\n";
                    else
                        $this->errMsg .= 'Moxa unavailable: Can not connect to: '.$this->ip."\r\n";
                    return false;
                }
                sleep(5);
                $retry=3;
                do
                {
                    $retry--;
                    sleep(1);
                } while ( $retry && (false === ($this->fp = fsockopen($this->ip, $this->port, $errno, $errstr, 10))) );
                if (! $retry)
                {
                    // Почтой, что моха недоступна
                    if ($this->debug >= 1)
                    {
                        echo $this->dev.': Moxa unavailable: Restart done, but can not connect to serial port to: '.$this->ip.':'.$this->port."\n";
                        echo $this->dev.": Moxa: $errstr ($errno)\n";
                    }
                    else
                        $this->errMsg .= 'Moxa unavailable: Restart done, but can not connect to serial port to: '.$this->ip.':'.$this->port."\r\nMoxa: $errstr ($errno)\r\n";
                    return false;
                }
            }
    
            // около 5 мс стандартная длительность тайм-аута для скорости 9600 Бод
            // но 30 милисекунд ещё не успевает, с запасом 50
            stream_set_timeout($this->fp, 0, 500000);
    
            // Инициализация соединения и передача пароля
            // Последний аргумент - рекурсия = 1, чтобы был только 1 заход без рекурсии, иначе вызовет сам себя и...
            if (false === ($init = $this->merc_gd($this->CRC_Modbus($this->dev.'0101010101010101'), 4, 1, $this->tNormal, 1)))
                return false;
            if ($this->debug >= 1)
                echo $this->dev.': Init:'.$init[0]."\n";
    
            // Проверим версию счётчика, если базовый № прошивки >= 9, то новые счётчики
            if (false === ($ret = $this->merc_gd($this->CRC_Modbus($this->dev.'0803'), 6, 1, $this->tNormal, 1)))
                return false;
            if ($this->debug >= 1)
                echo $this->dev.': Version:'.substr($ret[0], 2, 2).'.'.substr($ret[0], 4, 2).'.'.substr($ret[0], 6, 2)."\n";
            $this->ver = substr($ret[0], 2, 2);
            
            return true;
        }
    
        function close_connection()
        {
            if(!$this->fp)
                return;
            flush();
            fwrite($this->fp, hex2bin($this->CRC_Modbus($this->dev.'02')));
            stream_get_contents($this->fp);
            sleep(1);
            fclose($this->fp);
            $this->fp=0;
        }
    
        // $only_total - 0 или не 0, т.е. только итоговое значение, без текущих
        function get_from_nport_inner($only_total)
        {
    
            if (false === $this->init_connection())
                return false;
    
            // Серийный номер
            if (false === ($serial = $this->merc_gd($this->CRC_Modbus($this->dev.'0800'), 10, 1, $this->tSerial)))
                return false;
            if ($this->debug >= 1)
                echo $this->dev.': Serial:'.$serial[0]."\n";
    
            if (!$only_total)
            {
                // Сила тока (А) по фазам
                if (false === ($Ia = $this->merc_gd($this->CRC_Modbus($this->dev.'081621'), 12, 0.001)))
                   return false;
            if ($this->debug >= 1)
                echo $this->dev.": Ia: $Ia[0] + $Ia[1] + $Ia[2]\n";
    
                // Мощность P (Вт) по фазам
                $retry=3;
                $done=0;
                do {
                    if (false === ($Pv = $this->merc_gd($this->CRC_Modbus($this->dev.'081600'), 15, 0.01)))
                        return false;
                    if (round($Pv[0], 2) == round($Pv[1] + $Pv[2] + $Pv[3], 2))
                    {
                        $done=1;
                        $error = '';
                    }
                    else
                    {
                        if ( !$retry-- )
                            $done=1;
                        $error = "error, ".round($Pv[1] + $Pv[2] + $Pv[3], 2);
                    }
                    if ($this->debug >= 1)
                        echo $this->dev.": Pv: $Pv[0] = $Pv[1] + $Pv[2] + $Pv[3] $error\n";
                } while( !$done );
        
                // Напряжение U (В) по фазам
                if (false === ($Uv = $this->merc_gd($this->CRC_Modbus($this->dev.'081611'), 12, 0.01)))
                    return false;
                if ($this->debug >= 1)
                    echo $this->dev.": Uv: $Uv[0] + $Uv[1] + $Uv[2]\n";
    
                // Коэффициент мощности (С) по фазам
                $retry=3;
                $done=0;
                do {
                    if (false === ($Cos = $this->merc_gd($this->CRC_Modbus($this->dev.'081630'), 15, 0.001)))
                        return false;
                    if (round($Cos[0], 1) == round(($Cos[1] + $Cos[2] + $Cos[3])/3, 1))
                    {
                        $done=1;
                        $error = '';
                    }
                    else
                    {
                        if ( !$retry-- )
                            $done=1;
                        $error = "error, ".round(($Cos[1] + $Cos[2] + $Cos[3])/3, 1);
                    }
                    if ($this->debug >= 1)
                        echo $this->dev.": Cos: $Cos[0]= ($Cos[1] + $Cos[2] + $Cos[3])/3 $error\n";
                } while( !$done );
                for ( $j = 1; $j < 4; $j++ ) {
                    if ($Cos[$j] > 1)
                        $Cos[$j]=1;
                }
    
                if ($this->db)
                {
                    $pgsql = new db('pgsql','');
                    $res=$pgsql->select('SELECT eid FROM electric_accounting_counters WHERE model_id=\''.$serial[0].'\'', 'num');
                    if($pgsql->error[0]!=00000){echo $pgsql->error[2];}
                    if (!isset($res[0][0]))
                        return false;
                    $eid=$res[0][0];
                    if($this->debug >= 1)
                        echo $this->dev.': eid='.$eid."\n";
                    $sql=<<<TEXT
                        INSERT INTO electric_accounting_piucos 
                        VALUES (DEFAULT, $eid, 
                            $Pv[1], $Pv[2], $Pv[3],
                            $Ia[0], $Ia[1], $Ia[2],
                            $Uv[0], $Uv[1], $Uv[2],
                            $Cos[0], $Cos[1], $Cos[2]);
    TEXT;
                    $pgsql->set($sql);
                }
            }
        
            $timea=date('Y-m-d H:00:00');
            $timeb=date('Y-m-d H:i:s');
            // Если первые 4 минуты часа
            if (strtotime($timeb) - strtotime($timea) < 240 || $this->debug >= 1)
            {
                // Общее потребление
                if (false === ($Tot = $this->merc_gd($this->CRC_Modbus($this->dev.'050000'), 19, 0.001, $this->t4byte)))
                    return false;
                if ($this->debug >= 1)
                    echo $this->dev.": Total: $Tot[0]\n";
                if (!isset($pgsql))
                {
                    $pgsql = new db('pgsql','');
                    $res=$pgsql->select('SELECT eid FROM electric_accounting_counters WHERE model_id=\''.$serial[0].'\'', 'num');
                    if($pgsql->error[0]!=00000){echo $pgsql->error[2];}
                    if (!isset($res[0][0]))
                        return false;
                    $eid=$res[0][0];
                    if($this->debug >= 1)
                        echo $this->dev.': eid='.$eid."\n";
                }
                $res=$pgsql->select('SELECT total FROM electric_accounting_last_value WHERE eid='.$eid, 'num');
                if (!isset($res[0][0])) {
                    if($this->debug >= 1)
                        echo $this->dev.': Last in DB=(none)'."\n";
                    if($this->debug >= 2)
                        echo $this->dev.": Insert first time 'last value'\n";
                    if ($this->db)
                        $pgsql->set('INSERT INTO electric_accounting_last_value VALUES ( DEFAULT, '.$eid.', '.round($Tot[0],3).' )');
                }
                else
                {
                    if($this->debug >= 1)
                        echo $this->dev.': Last in DB='.$res[0][0]."\n";
                    if($this->debug >= 2) {
                        echo $this->dev.": Insert energy\n";
                        echo $this->dev.': Save energy to DB(pre) Tot:'.round($Tot[0],3).' last:'.round($res[0][0],3)."\n";
                        echo $this->dev.': Save energy to DB='.round(round($Tot[0],3)-round($res[0][0],3),3)."\n";
                    }
                    if ($this->db) {
                        $pgsql->set('UPDATE electric_accounting_last_value SET total='.round($Tot[0],3).' WHERE eid='.$eid);
                        $pgsql->set('INSERT INTO electric_accounting_energy VALUES ( DEFAULT, '.$eid.', '.round(round($Tot[0],3)-round($res[0][0],3),3).' )');
                    }
                }
            }
    
            $timea=date('Y-m-d 00:05:00');
    //        $timea=date('Y-m-d H:05:00');
            $timeb=date('Y-m-d H:i:s');
            // Если между 5-ю минутами и 10-ю 0-го часа каждого дня
            if ((strtotime($timeb) - strtotime($timea) < 240 && strtotime($timeb) > strtotime($timea)) || $this->debug >= 1)
            {
                // проверяем за последние 2 месяца
                if (!isset($pgsql))
                {
                    $pgsql = new db('pgsql','');
                    $res=$pgsql->select('SELECT eid FROM electric_accounting_counters WHERE model_id=\''.$serial[0].'\'', 'num');
                    if($pgsql->error[0]!=00000){echo $pgsql->error[2];}
                    if (!isset($res[0][0])) {
                        if ($this->debug >= 1) {
                            $this->close_connection();
                            return true;
                        }
                        else
                            return false;
                    }
                    $eid=$res[0][0];
                    if($this->debug >= 1)
                        echo $this->dev.': eid='.$eid."\n";
                }
                $timeb=date('Y-m-d');
    //            $timea=date('Y-m-d',strtotime('now - 2 month'));
    //            $timea=date('Y-m-d',strtotime('now - 3 week'));
                $timea=date('Y-m-d',strtotime('now - 2 week'));
    //            $timea=date('Y-m-d',strtotime('now - 2 day'));
                $sql=<<<SQL
                    WITH tt AS (
                        SELECT generate_series('{$timea}'::timestamp, '{$timeb}', '1 hour') AS dt) 
                    SELECT * FROM tt 
                    WHERE tt.dt NOT IN (
                        SELECT indication_date 
                        FROM electric_accounting_energy
                        WHERE indication_date BETWEEN '{$timea}' AND '{$timeb}' 
                        AND eid={$eid});
    SQL;
                $res=$pgsql->select($sql, 'num');
                if (!isset($res[0][0])) {
                    $this->close_connection();
                    return true;
                }
    
                // получим последнюю записанную ячейку памяти
                if (false === ($ret = $this->merc_gd($this->CRC_Modbus($this->dev.'0813'), 12, 1, $this->tNormal)))
                    return false;
                // В разных версиях - разная адресация
                if($this->ver >= 9) {
                    $addr = substr($ret[0], 3, 3).'0';
                    $h_addr = substr($ret[0], 2, 1);
                }
                else {
                    // адрес в памяти
                    $addr = substr($ret[0], 2, 4);
                    // байт состояния записи, нам нужен только 4-й бит (маской 10h) - это 17-й бит(старший) адреса памяти
                    $h_addr = hexdec(substr($ret[0], 6, 2)) & 16 / 16;
                }
                if($this->debug >= 3) {
                    echo $this->dev.': addr='.$addr."\n";
                    echo $this->dev.': h_addr='.$h_addr."\n";
                }
                // время и дата
                $hh = substr($ret[0], 8, 2); $mm = substr($ret[0], 10, 2);
                $dd = substr($ret[0], 12, 2); $mo = substr($ret[0], 14, 2); $yy = substr($ret[0], 16, 2);
                $timeb = "$yy-$mo-$dd $hh:$mm:00";
                if($this->debug >= 1)
                    echo $this->dev.': Last saved time:'.$timeb."\n";
                // длительность периода интегрирования
                $period = hexdec(substr($ret[0], 18, 2));
                if($this->debug >= 1)
                    echo $this->dev.': Integrity period:'.$period."\n";
    
                // прочитаем вариант исполнения счётчика
                if (false === ($ret = $this->merc_gd($this->CRC_Modbus($this->dev.'0812'), 9, 1, $this->tNormal)))
                    return false;
                // проверим есть ли память
                if ((hexdec(substr($ret[0], 4, 2)) & 32) != 32 ) {
                    $this->close_connection();
                    return true;
                }
                // постоянная счётчика
                switch (hexdec(substr($ret[0], 4, 2)) & 15) {
                    case 0:
                        $const = 5000;
                        break;
                    case 1:
                        $const = 25000;
                        break;
                    case 2:
                        $const = 1250;
                        break;
                    case 3:
                        $const = 500;
                        break;
                    case 4:
                        $const = 1000;
                        break;
                    case 4:
                        $const = 250;
                        break;
                    default:
                        if ($this->debug >= 1)
                            echo $this->dev.': Counter error: const error: '.$this->ip.':'.$this->port.' dev:'.$this->dev."\n";
                        else
                            $this->errMsg .= 'Counter error: const error: '.$this->ip.':'.$this->port.' dev:'.$this->dev."\r\n";
                        return true;
                }
                if($this->debug >= 1)
                    echo $this->dev.': Counter const:'.$const."\n";
                // сколько памяти (нужен ли 17-й бит)
                $ext_mem = hexdec(substr($ret[0], 8, 2)) & 128 / 128;
                if($this->debug >= 2)
                    echo $this->dev.': ext_mem='.$ext_mem."\n";
    
                // сколько памяти используется под запись мощности
                // если установлен хотя бы 1 бит учёта любого вида технических потерь, то расширенная память не используется
                if ($ext_mem == 1) {
                    if (false === ($ret = $this->merc_gd($this->CRC_Modbus($this->dev.'081e'), 5, 1, $this->tNormal)))
                        return false;
                    if($this->debug >= 2)
                        echo $this->dev.': check using ext_mem...'."\n";
                    if (hexdec(substr($ret[0], 2, 2)) > 0)
                        $ext_mem = 0;
                    if($this->debug >= 2)
                        echo $this->dev.': ext_mem='.$ext_mem."\n";
                }
    
                // Теперь в цикле пройдём по всем датам
                $i = 0;
                $db_date = $res[0][0];
                if($this->debug >= 1)
                    echo $this->dev.': Reading memory...'."\n";
                while (1) {
                    if($this->debug >= 1)
                        echo $this->dev.': Finding date:'.$db_date."\n";
                    // Найдём разницу во времени
                    $diff = strtotime($timeb)-strtotime($db_date);
                    if($this->debug >= 3)
                        echo $this->dev.': orig diff='.$diff."\n";
                    // Сколько периодов между ними
                    $diff = $diff / 60 / $period;
                    if($this->debug >= 3)
                        echo $this->dev.': priod diff='.$diff."\n";
                    // Теперь надо отступить ещё на час минус учтённый последний период,
                    // т.к. разница на конец часа(т.е. конец последнего периода)
                    $diff = $diff + 60 / $period - 1;
                    // Сколько ячеек памяти надо отступить
                    $diff *= 16;
                    if($this->debug >= 3) {
                        echo $this->dev.': cell diff='.$diff."\n";
                        echo $this->dev.': cell diff(hex)='.$this->zeropad(dechex($diff),4)."\n";
                    }
                    // Получим показания периодов часа
                    $j = 60 / $period;
                    $p = 0;
                    while( $j > 0 ) {
                        if ($ext_mem) {
                            $addr_a = hexdec($addr)+hexdec('10000')*$h_addr;
                            $addr_b = $addr_a - $diff;
                            if ($addr_b < 0 )
                                $addr_b = hexdec('20000')+$addr_b;
                        }
                        else {
                            $addr_a = hexdec($addr);
                            $addr_b = $addr_a - $diff;
                            if ($addr_b < 0 )
                                $addr_b = hexdec('10000')+$addr_b;
                        }
                        if($this->debug >= 3) {
                            echo $this->dev.': addr_a='.$this->zeropad(dechex($addr_a),4)."\n";
                            echo $this->dev.': addr_b='.$this->zeropad(dechex($addr_b),4)."\n";
                        }
                        if ($addr_b < hexdec('10000'))
                            $bit = '03';
                        else {
                            $bit = '83';
                            $addr_b -= hexdec('10000');
                        }
                        $addr_r = $this->zeropad(dechex($addr_b), 4);
                        if($this->debug >= 3)
                            echo $this->dev.': addr_r='.$addr_r."\n";
                
                        // Считываем показания
                        if (false === ($ret = $this->merc_gd($this->CRC_Modbus($this->dev.'06'.$bit.$addr_r.'0f'), 18, 1, $this->tNormal)))
                            return false;
                
                        // время и дата
                        $hh = substr($ret[0], 4, 2); $mm = substr($ret[0], 6, 2);
                        $dd = substr($ret[0], 8, 2); $mo = substr($ret[0], 10, 2); $yy = substr($ret[0], 12, 2);
                        $timea = "$yy-$mo-$dd $hh:$mm:00";
                        if($this->debug >= 1)
                            echo $this->dev.': Getting date:'.$timea."\n";
                        // проверим та ли дата в ячейке памяти
                        if (abs(strtotime($db_date)-strtotime($timea)) > 3600 ) {
                            if ($this->debug >= 1)
                                echo $this->dev.': Counter error: memory error, to long period ('.$db_date.'): '.$this->ip.':'.$this->port.' dev:'.$this->dev."\n";
                            else
                                $this->errMsg .= 'Counter error: memory error, to long period ('.$db_date.'): '.$this->ip.':'.$this->port.' dev:'.$this->dev."\r\n<br>";
                            $i++;
                            break;
                        }
                        // Показание за период
                        $p += hexdec(substr($ret[0], 18, 2).substr($ret[0], 16, 2)) * (60 / $period) / (2 * $const);
                        $j--;
                        $diff -= 16;
                    }
                    if($this->debug >= 1)
                        echo $this->dev.': Reading 1 cell done'."\n";
                    if ( $j > 0 ) {
                        $i++;
                        if (isset($res[$i][0])) {
                            $db_date = $res[$i][0];
                            continue;
                        }
                        else
                            break;
                    }
                    $p = $p / ( 60 / $period );
    
                    // Запишем в базу
                    if (isset($res[$i][0])) {
                        if ($res[$i][0] == $db_date) {
                            if ($this->debug >= 1)
                                echo $this->dev.': INSERT ( DEFAULT, '.$eid.', '.$p.', \''.$db_date.'\' )'."\n";
                            if ($this->db)
                                $pgsql->set('INSERT INTO electric_accounting_energy VALUES ( DEFAULT, '.$eid.', '.$p.', \''.$db_date.'\' )');
                            $i++;
                            if (isset($res[$i][0])) {
                                if (date('Y-m-d H:i:00',strtotime($db_date.' + 1 hour')) == $res[$i][0]) 
                                    $db_date = $res[$i][0];
                                else 
                                    $db_date = date('Y-m-d H:i:00',strtotime($db_date.' + 1 hour'));
                            }
                            else 
                                $db_date = date('Y-m-d H:i:00',strtotime($db_date.' + 1 hour'));
                        }
                        else {
                            if ($this->debug >= 1)
                                echo $this->dev.': UPDATE energy='.$p.' WHERE eid='.$eid.' AND indication_date=\''.$db_date.'\''."\n";
                            if ($this->db)
                                $pgsql->set('UPDATE electric_accounting_energy SET energy='.$p.' WHERE eid='.$eid.' AND indication_date=\''.$db_date.'\'');
                            $db_date = $res[$i][0];
                        }
                    }
                    else {
                        if ($this->debug >= 1)
                            echo $this->dev.': UPDATE energy='.$p.' WHERE eid='.$eid.' AND indication_date=\''.$db_date.'\' )'."\n";
                        if ($this->db)
                            $pgsql->set('UPDATE electric_accounting_energy SET energy='.$p.' WHERE eid='.$eid.' AND indication_date=\''.$db_date.'\'');
                        break;
                    }
                }
            }
            // Закрытие соединения
            $this->close_connection();
    
            return true;
        }
    
        function get_from_nport_main($only_total=0)
        {
            $retry=3;
    
            while ( $retry && (false === $this->get_from_nport_inner($only_total)) ) 
            {
                $retry--;
                $this->close_connection();
                sleep(5);
            }
        
            if (! $retry)
            {
                if ($this->debug >= 1)
                    echo $this->dev.': Moxa error: Communications error with: '.$this->ip.':'.$this->port."\n";
                else
                    $this->errMsg .= 'Moxa error: Communications error with: '.$this->ip.':'.$this->port."\r\n\r\n";
            }
        }
    
        function get_from_nport($ip, $port, $devs)
        {
            $this->ip = $ip;
            $this->port = $port;
            
            if(!isset($this->debug))
                $this->debug = 0;
                
            foreach($devs as $dev) {
                $this->dev = $dev[0];
                if(!isset($dev[1]))
                    $this->get_from_nport_main();
                else
                    $this->get_from_nport_main($dev[1]);
            }
        }
    }
    
    function shutdown() {
    //    ob_end_clean();
            posix_kill(getmypid(), SIGHUP);
    }
    
    // ---------------------------------------------------------------------
    $cntr = new eCounters;
    $cntr->debug = 0;
    $executed = 0;
    // Debug
    //$cntr->debug = 2;
    //$cntr->db = false;
    //$cntr->get_from_nport('192.168.1.1', 4001, array(['25'],['31'],['3e'],['4e']));
    //exit;
    // ---------------------------------------------------------------------
    // K трансф = K напряжения * K тока
    //
    // ---------------------------------------------------------------------
    // Подстанция
    //
    // 1. 21(15h)  Ф. Моторная  K=60*400=24000  - 2-й транс.
    // 2. 37(25h)  Ф. 157       K=60*400=24000  - 1-й транс
    $pid = pcntl_fork();
    if($pid == -1)
        // Fork not woking
        $cntr->get_from_nport('192.168.1.1', 4002, array(['15'],['25']));
    elseif($pid) 
        // Parent
        $executed++;
    else{
        // Child
        register_shutdown_function('shutdown');
        $cntr->get_from_nport('192.168.1.1', 4002, array(['15'],['25']));
        if ($cntr->errMsg != '')
            mail(MAIL_FROM, 'Moxa error', $cntr->errMsg, 'From: moxa@eldin.ru' . "\r\n");
        exit;
    }
    
    
    // ---------------------------------------------------------------------
    if ($cntr->errMsg != '')
        mail(MAIL_FROM, 'Moxa error', $cntr->errMsg, 'From: moxa@eldin.ru' . "\r\n");
    
    while($executed) {
        pcntl_wait($status);
        $executed--;
    }
    
    unlink($lock_file);
    ?>
    


    1. iegova
      17.11.2016 13:57

      Единственный недостаток это то что нельзя использовать для коммерческого учета электроэнергии так как система не сертифицирована, ибо opensource и сертификация понятия взаимоисключающие. Кстати ни у кого нет протокола обмена для концентраторов меркурий 225.11 и 225.12?


    1. s60
      23.11.2016 10:18

      как использовать php на клиентской стороне (формирование web-страниц пользователю) мне понятно, а как вы цикличность опрос+БД реализовали (при том что сбор данных это независимо выполняемая задача от просмотра этих данных)?


      1. viras777
        23.11.2016 14:38

        На FreeBSD в cron'е выполняем приведённый выше php'ник каждые 5 минут. Чтобы не было дважды запущенных периодических задач, создаём лок-файл.


        1. s60
          23.11.2016 16:41

          как php встраивается в html и/или генерит html файл я понимаю… а как cron в данном случае запускает скрипт? и почему php тогда выбрали в этом случае?


          1. viras777
            23.11.2016 20:17

            Вот такие строки в /etc/crontab:
            #minute hour mday month wday who command
            */5 * * * * <имя пользователя> php /usr/local/<путь>/electric_accounting_counters.php db_dir=<имя БД>

            Как когда-то давно говорил мой научный руководитель, можно знать один язык программирования, два, три, а потом вы уже знаете все. Утрированно конечно, но близко к истине. Если серьёзно, то не видим причин плодить сущности, когда php устраивает.


  1. s60
    23.11.2016 10:07

    статью плюсануть не успел (срок голосования истек), плюсанул в карму…
    статьи не пишутся в том числе потому. что это занимает немало времени (а тем более хорошие, полноЦЕННЫЕ, с картинками)… сами видите — написали через три года…
    у меня есть два ынтерпрайз проекта, по которым можно статьи сделать, но времени нет…
    так глядишь и в cpp кто-нибудь переведет ваш код…
    насчет документации на сайте openSCADA — регистрируйтесь и дополняйте wiki своими примерами использования той или иной сущности (примеров использования там очень не хватает)… это же open source…


  1. s60
    23.11.2016 10:15

    хотя уже не столько примеры нужно сколько уже время патчей (некоторые вещи в QT гвоздями прибиты, например, поле ввода перечня атрибутов у параметра в узле Modbus — строка в 101 символ, а потомо перенос на строки и неважно что монитор 24" 1920х1200 и еще полно свободного места) и элементов (свой протокол для ширпотребной шалабушки, свои виджеты )… сколько лет уже разрабатывается продукт…


    1. Wedmer
      23.11.2016 12:57

      Патчи Роман прекрасно принимает. Так что правьте и предлагайте.


      1. s60
        23.11.2016 16:40

        я про те, которые расходятся с идеологией и дизайнерским вкусом Романа… а так да, принимает… если…


        1. Wedmer
          24.11.2016 04:47

          Ну, в таком случае или терпеть, или искать другие пути. Например мне не нравится ни стиль, ни архитектура приложения. Но писать свое пока нет времени.
          Ну и да, при создании модулей для рабочей ветки без внесения в общую кодовую базу нужно постоянно следить за каждым чихом. Внешне API может даже и не поменяться, но ваш модуль уже не будет работать должным образом.