Перенаправление данных из COM-порта в web. Доработка

    Недавно я опубликовал статью «Перенаправление данных из COM-порта в web», в которой описал прототип системы, транслирующей строки из последовательного порта компьютера в веб-браузер. В той статье я указал направления, в которых надо доработать прототип, чтобы приблизить его к продакшен-стадии:
    — никакой дизайн веб-страницы
    — в каждый момент времени данные получит только один веб-клиент
    — очень ограниченный набор браузеров, с помощью которых можно получить доступ. Например, не работает ни в Internet Explorer 8, ни в браузере из Android 2.3.5
    — требуется установка python

    Через некоторое время я решил не оставлять его в таком виде и доработать. Под катом результат доработки и описание того, как я устранил все перечисленные недостатки.



    Сразу покажу итоговый результат:

    На этом видео видно, что строки из COM-порта отображаются сразу в трёх браузерах одновременно: в Firefox и IE 8 на том же компьютере, к которому подключена Arduino, и на смартфоне.
    Сама Arduino передаёт строку «Температура: XXXXXX», а строку с датой-временем и номером строки — одна из частей бэкенда.

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

    Плохой дизайн веб-страницы



    В предыдущей статье я писал, что в создании веб-фронтенда я разбираюсь от слова «никак», поэтому создание нормальной веб-страницы было для меня самым сложным. К счастью я почти сразу же обнаружил сайт w3schools.com, на котором открыл для себя Bootstrap и нашёл хорошие учебники по Ajax и jQuery. Для матёрых фронтенд-разработчиков представленные на этом сайте учебники, наверное, вызовут только улыбку, но для таких новичков, как я, это самое то, что надо.
    Самое приятное в этих учебниках то, что они очень небольшие и очень по существу. Без размазывания каши по тарелке. В принципе, одного вечера на изучение достаточно, чтобы начать что-то делать.

    Оказалось, что с помощью Bootstrap наваять более-менее приемлемый дизайн веб-страницы — это не так уж и сложно. Он, конечно, не сделает из вас Артемия Лебедева, но запрограммировать интерфейс с пользователем сделать на нём можно очень быстро.

    Единственное, что я не смог понять, как с его помощью сделать нужное мне разбиение страницы на две части: большую, в которой текст отображается по середине в вертикальной плоскости, и маленькую, которая всё время «прижата» к нижнему краю окна браузера. Но тут на помощь пришла статья «Vertical Centering in CSS». В результате получилась вот такая заготовка под веб-страницу:

      <!-- Vertical aligment of text from "Vertical Centering in CSS" at 
      http://www.jakpsatweb.cz/css/css-vertical-center-solution.html -->
    <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN">
    <html>
    <head>
        <style type="text/css">
        html, body {
            height: 100%;
            margin: 0px;
        }
      </style>
    </head>
    
    <body>
    
      <div style="display: table; height: 90%; width: 100%; overflow: hidden;">
        <div style=" display: table-cell; vertical-align: middle;">
          <div style="text-align: center">
            data place<br>
            пока нет данных
          </div>
        </div>
      </div>
    
      <div style="display: table; height: 10%; width: 100%; overflow: hidden;">
        <div style=" display: table-cell; vertical-align: middle;">
            <div style="text-align: right;">
                buttons
            </div>
        </div>
      </div>
    </body>
    </html>
    


    Практически весь остальной код страницы получился после чтения учебников по Bootstrap и JavaScript (раздел JS HTML DOM). Исключение — это код jQuery, который обновляет данные на странице. Но об этом чуть позже.

    Ограниченный набор поддерживаемых браузеров



    Слабая поддержка браузерами предыдущего прототипа была обусловлена выбранной технологией доставки обновлений информации: Server-Sent Events. Поэтому в этот раз я решил воспользоваться старой проверенной временем технологией Ajax. Использование Ajax-а приводит к увеличению веб-трафика, но зато, по-моему, должно работать в максимальном количестве браузеров.
    Ещё одним недостатком Ajax-а можно считать тот факт, что, если не предпринять никаких специальных мер, то возможны пропуски строк, которые передаются через COM-порт: если строки в последовательный порт будут поступать очень быстро, а Ajax-запросы приходить реже, что все строки между запросами будут не видны клиенту. Но для задачи отображения, например, текущей температуры — это совсем не страшно.

    Можно было бы, наверное, воспользоваться технологией WebSockets, но, насколько я понял, в IE она поддерживается только с 10-й версии, а у меня IE 8, поэтому я даже не стал прорабатывать это направление.
    Ещё буквально на днях в какой-то из статей на хабре или гиктаймс я наткнулся на упоминание библиотеки SockJS, которая, похоже, умеет обходить отсутствие WebSockets, но, во-первых, для неё требуется спец. поддержка на стороне сервера, а, во-вторых, у меня к этому моменту нормально работал Ajax, поэтому и она осталась без моего внимания.

    Итак, Ajax. Когда-то давно я пытался изучить эту технологию. Но все бумажные учебники, которые мне попадались, были слишком нудными и я очень быстро бросал это дело. А вот на уже упомянутом w3schools.com учебник оказался очень хорошим. В результате достаточно быстро получился следующий код:

            function get_data()
            {
                var xmlhttp;
                xmlhttp=new XMLHttpRequest();
                xmlhttp.open("GET","/get_serial?r=" + Math.random(),true);
                xmlhttp.onreadystatechange=function()
                  {
                  if (xmlhttp.readyState==4 && xmlhttp.status==200)
                    {
                    document.getElementById("data").innerHTML=xmlhttp.responseText;
                    get_data();
                    }
                  }
                xmlhttp.send();
            }
    

    который вызывался при окончании загрузки страницы:
    <body onload="get_data()">
    


    В этом коде надо, наверное, обратить внимание на два момента. Во-первых, на строчку
                xmlhttp.open("GET","/get_serial?r=" + Math.random(),true);
    

    Именно в ней происходит обращение к веб-серверу за очередной строкой из COM-порта. Добавка
    r=" + Math.random()
    

    нужна для того, чтобы Internet Explorer не кэшировал ответы. В противном случае он пошлёт только один Ajax-запрос, получит ответ и больше уже не будет обращаться к серверу. В интернете я видел решения проблемы кэширования путём посылки со стороны сервера специальных HTTP-заголовков
            response.headers['Last-Modified'] = datetime.now()
            response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0, max-age=0'
            response.headers['Pragma'] = 'no-cache'
            response.headers['Expires'] = '-1'
    

    но у меня это почему-то не заработало.
    Альтернативный вариант — это использование POST вместо GET. Говорят, что такие запросы IE не кэширует, но я не проверял.
    Кстати, jQuery, использует точно такой же способ борьбы с кэшированием — он к УРЛ-у тоже добавляет случайную строку. Только выглядит она несколько иначе.

    Второй момент — это последовательность строк
                xmlhttp.open("GET","/get_serial?r=" + Math.random(),true);
    

    и
                xmlhttp.onreadystatechange=function()
                ...
    


    Изначально они стояли в другом порядке. И в Internet Explorer-е это приводило к странным проблемам — он выжирал всю доступную память. И только после этого обновлял данные. На чём и заканчивал свою работу.
    Только на сайте XmlHttpRequest.ru я нашёл, что при повторном использовании объекта XMLHttpRequest рекомендуется сначала вызвать метод open() и только после этого менять свойство onreadystatechange.

    Получив столько проблем с IE на ровном месте, я решил, что надо бы воспользоваться библиотекой, в которой подобные нюансы скорее всего уже учтены. Поскольку для Bootstrap-а на странице уже загружался jQuery
      <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
    

    я решил, что негоже не воспользоваться поддержкой Ajax, встроенной в эту библиотеку. Поэтому код обновления данных преобразовался в такой:
            function on_load(responseTxt, statusTxt, xhr){
                if( statusTxt == "success" ){
                    $("#data").load("/get_serial?r=" + Math.random(), on_load )
                } else {
                    $("#data").text( "Error: " + xhr.status + ": " + xhr.statusText );
                }
            }
            $(document).ready(function(){
                    on_load("", "success", 3);
                });
    


    Один клиент в каждый момент времени



    В предыдущей статье я уже писал про возможный путь решение этой проблемы: разбиение бэкенда на две части, использование ZMQ для их связи + многопользовательский http-сервер в качестве второй части. В качестве такого сервера я выбрал Flask. Я не проводил никаких сравнений альтернатив, он просто попался мне первым. Выяснилось, что его можно запустить в режиме параллельной обработки http-запросов и этого оказалось достаточным. Для запуска такого режима достаточно передать ему параметр
    threaded = True (см. stackoverflow.com/questions/14672753/handling-multiple-requests-in-flask):

        app.run(host='0.0.0.0', debug=False, threaded=True)
    


    Помимо многопоточности Flask предоставляет ещё очень удобный механизм маршрутизации, т.е. соответствия отдельных процедур определённым http-запросам. Так что для создания простеньких веб-приложений Flask — очень милое дело.

    Но больше всего мне хотелось бы рассказать про библиотеку ZMQ — продемонстрировать её потрясающие возможности.
    Вот код демонстрационного ZMQ-сервера:
    import zmq
    import time
    
    context = zmq.Context.instance()
    
    pub_sock = context.socket(zmq.PUB)
    pub_sock.bind( 'tcp://*:12345' )
    
    count = 0
    def get_full_line_from_serial():
        global count
        
        count += 1
        return time.strftime( '%Y.%m.%d %H:%M:%S' ) + ': string N %d' % count
    
    
    while True:
        line = get_full_line_from_serial()
        print line
        pub_sock.send( line )
        time.sleep(1)
    

    который имитирует чтение из COM-порта. На самом деле он каждую секунду генерирует новую строку, «публикует» её (т. е. отсылает всем подписавшимся клиентам) и для отладки выводит её на печать.

    А вод код клиента:
    import zmq
    
    context = zmq.Context.instance()
    zmq_sub_sock = context.socket(zmq.SUB)
    zmq_sub_sock.setsockopt(zmq.SUBSCRIBE, '')
    zmq_sub_sock.connect( 'tcp://localhost:12345' )
    
    poller = zmq.Poller()
    poller.register( zmq_sub_sock, zmq.POLLIN )
    
    while True:
    
        socks = dict(poller.poll(timeout=5000))
        if zmq_sub_sock in socks:
            print zmq_sub_sock.recv()
        else:
            print 'No data within 5 sec'
    

    который открывает ZMQ-сокет, подписывается на все возможные сообщения и подключается к серверу. После чего в бесконечном цикле ожидает новых данных и выводит их.

    А вот демонстрация их совместной работы:

    На что хочется обратить внимание. Во-первых, клиента можно запустить до запуска сервера. И это никак не скажется на его работоспособности. Во-вторых, даже падение сервера не приведёт к падению клиентов — как только сервер перезапустится, клиенты снова начнут получать сообщения от него. Ну не фантастика ли? И это при полном отсутствии каких-либо специальных инструкций в исходных кодах клиента и сервера. Сколько кода пришлось бы написать самостоятельно для реализации подобного функционала при использовании обычных TCP/IP сокетов?
    И это только маленькая толика того, что умеет библиотека ZMQ. В очередной раз крайне настоятельно рекомендую присмотреться к этой библиотеке.

    Standalone приложение



    Я уже, вроде бы, упоминал, что python слабо приспособлен к созданию standalone приложений. Это интерпретируемый язык, для обычной работы которого требуется программа-интерпретатор. К сожалению для него не существует компилятора, который бы умел генерировать нативный бинарный код. Мне известны две программы, позволяющие создать из скрипта некое подобие самостоятельного приложения: py2exe и pyInstaller. Сам я чаще всего пользуюсь вторым, поэтому и в данном проекте решил использовать его.

    pyInstaller берёт питоновский скрипт, анализирует все файлы, от которых он зависит (определяет все импортируемые модули и подключаемые динамические библиотеки) и далее либо собирает их все в отдельную папку, либо упаковывает в некий аналог самораспаковывающегося архива. Туда же добавляется код собственно интерпретатора, а также небольшая стартовая часть, которая при старте подготавливает среду исполнения и запускает интерпретацию целевого скрипта.

    Поскольку ни создатели питона, ни создатели библиотек под него не задумывались о таком возможном использовании, всё это работает несколько «через пень-колоду». Основные проблемы заключаются в подготовке списка всех необходимых библиотек и модулей. Потому что многие авторы библиотек используют неявное импортирование, при котором нет явного указания на то, какой модуль будет импортирован в скрипт или другой модуль. Соответственно частенько после автоматического анализа скрипта pyInstaller-ом надо руками дописывать в настроечный файл (с расширением .spec) имена дополнительных модулей и/или путей, где их искать. Вместе с pyInstaller-ом идёт большой набор готовых функций для таких библиотек, но жизнь не стоит на месте. В частности так оказалось с текущей версией ZMQ — разработчики pyZMQ (биндинга ZMQ к python-у) изменили механизм импортирования вспомогательных библиотек, в результате чего собранное pyInstaller-ом приложение не запускается. Народ с этим делом уже разобрался и соответствующий патч для pyInstaller-а подготовили. Патч этот рабочий, но в официальный релиз пока не попал, поэтому пришлось руками патчить pyInstaller. Код патча смотри на github.com/pyinstaller/pyinstaller/pull/110/files

    Но даже этого патчка оказалось мало в случае, если standalone приложение должно представлять из себя только один исполнимый файл, а не целую папку. Пришлось руками ещё корректировать .spec-файл, чтобы библиотека libsodium.pyd из дистрибутива ZMQ попала в нужное место при распаковке .exe-файла.

    Вторая проблема заключалась в модуле multiprocessing. Бэкенд я разбил на две части, которые должны работать параллельно. Мне показалось неправильным, если для запуска бэкенда придётся запускать две отдельные программы. А использовать многопоточность (multithreading) мне показалось неправильным из-за наличия GIL (Global Interpretation Lock). Казалось бы, чего проще написать простенький скрипт, который при старте будет просто порождать два новых процесса: один для ZMQ-сервера, читающего данные из COM-порта, второй — для HTTP-сервера, отвечающего на запросы веб-клиентов. И действительно, при работе в обычном режиме (т. е. при «ручном» запуске интерпретатора) прекрасно работает следующий скрипт:

    # -*- coding: utf-8 -*-
    
    from multiprocessing import Process
    import serial_to_zmq
    import zmq2web_using_flask
    import time
    
    
    def main():
        args = get_command_line_params()
        
        p1 = Process( target=serial_to_zmq.work, args=(args.serial_port_name, args.serial_port_speed, args.zmq_pub_addr) )
        p1.start()
        
        p2 = Process(target=zmq2web_using_flask.work, args=(args.zmq_sub_addr,))
        p2.start()    
        
        print 'Press Ctrl+C to stop...',
        while True:
            time.sleep(10)
        
        
    def get_command_line_params():
        ...
        
    if __name__ == '__main__':
        main()
    


    Но после обработки pyInstaller-ом полученный .exe нормально не работал. Происходил запуск нескольких ZMQ-серверов и Flask-приложений с соответствующими сообщениями о том, что «сокет уже занят» и ещё какие-то странности. Выяснилось, что при запуске Flask-приложения ему надо передавать параметр debug=False:

        app.run(host='0.0.0.0', debug=False, threaded=True)
    


    а для модуля multiprocessing нужно вызывать специальную функцию freeze_support(), которая нужна в том режиме (froozen), который создаётся при интерпретации скрипта в случае standalone приложения, созданного pyInstaller-ом.

    Вообщем, итог по данному пункту таков: создать standalone приложение из питоновского скрипта можно, но это не просто.

    Все исходные тексты можно взять на гитхабе: github.com/alguryanow/serial2web-2

    P.S. Ещё учебник по Bootstrap: www.tutorialrepublic.com/twitter-bootstrap-2.3.2-tutorial
    Поделиться публикацией
    Похожие публикации
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 13
    • +1
      как будто из прошлого статью прочитал. Посмотрел на дату на компьютере. Да действительно 2015 год на дворе. А за статью спасибо.
      • 0
        Уиии… Смотри я тоже такое делал от нечего делать…
        http://www.youtube.com/watch?v=gEbNQu9mXT0
        • 0
          видео уже не доступно. Не мог бы сделать, чтобы можно было кнопки прожимать в web форме и слать сигналы на ардуинку. Был бы очень полезен двухсторонний обмен данными.
          • 0
            Что значит «видео недоступно»? У меня видео работают.
            Сделал вариант с двунаправленным обменом: https://github.com/alguryanow/serial2web-2/tree/ChipaKraken-patch-1
        • 0
          а почему новый репозиторий? можно же было просто выкатить новую версию в старый.
          • 0
            Наверное, от безграмотности. Я не смог быстро сообразить, как сделать так, чтобы при желании можно было «развивать» обе версии в одном репозитории. Я пользуюсь mercurial. Можете показать команды hg, с помощью которых можно правильно и красиво поддерживать обе версии?
            • 0
              hg branch newversion
              
              создает новый branch (ветку)
              hg update newversion
              
              переключает на ветку
              hg branch
              
              узнать на какой ветке в данный момент находишься

              дефолтная ветка имеет название default
              • 0
                Спасибо за команды.

                А на гитхаб это нормально протолкнётся?

                Если я сделаю «hg branch version-2», то надо ли первыми коммитом после этого удалить все старые файлы? Ведь новая версия, по большому счёту, не является развитием старой версии — можно сказать, что она написана почти с чистого листа.

                У меркуриала есть особенность: если сделать clone, то в копии активной ревизией будет последняя из ветки default. Если не знать об этом (или забыть), то после клонирования можно начать редактировать неправильную ревизию. У git-а таких проблем нет?
                • 0
                  В теории должно на гитхаб протолкнуться, я, честно говоря, не работал в связке mercurial+github. Чтобы не было опасений, можно пользоваться официальным приложение. Там можно и брэнчи создавать и коммитить и пушить.

                  Да, если создаем новый брэнч, то старые файлы удаляем первым коммитом.

                  Честно говоря, не знаю.
                  • 0
                    Я сделал, как Вы предложили, но, по-моему, получилось так себе. Если на локальной машине в Hg Workbench ответвление видно хорошо (ветка пошла другим цветом, у каждого коммита показывается название ветки), то на гитхабе это не понятно. Кроме того, на гитхабе коммиты имеют совершенно другие хэши и не понятно, как мне в README.md ссылаться на нужный коммит.

                    Так что пока я делаю вывод, что идея вести обе версии в одном репозитории, возможно, и правильная, но, по крайней мере, в случае связки mercurial + github плохо реализуемая.
          • 0
            Хорошая статья, но, было бы гораздо интереснее если бы вы, вдруг, научились слать информацию с COM-порта непосредственно в браузер, что, на текущий момент, без костылей невозможно. К сожалению.
            У нас на одном маленьком проекте используется standalone-приложение для считывания данных с карточек для пропускной системы. Однако у нас не реализовано самое интересное — чтобы браузер\сессия и приложение на COM-порте однозначно знали что они запущены на одной машине. Т.е. в окно браузера открытое на определенной машине приходят данные с COM-порта на этой машине.
            • 0
              github.com/billhsu/jUART
              Но учитывая, что NPAPI из Хрома выпилили, этот проект уже мало перспективен. Есть другое решение:
              github.com/qzind/qz-print
              Раньше это был Java-апплет. Теперь из него сделали аналог того же решения, что приводится в статье, но с фолбеком на Java-апплет для нерадикальных браузеров. Думаю, это самое перспективное решение. Я всё ещё надеюсь, что Java-плагин для Хрома когда-нибудь появится.
              • 0
                Простите, но я не понял, что Вы хотите получить в итоге.

                Что касается стыковки браузера с COM-портом, то мне кажется, что желание сделать это без костылей — это из области фантастики. Браузер — это программа, которая (если забыть про разные костыли) работает только с http-серверами. Т.е. вы с одной стороны имеете standalone-программу, которая работает с COM-портом, но, насколько я понял, не умеет отвечать на http-запросы, а с другой стороны — браузер, который (в основном!) умеет только посылать http-запросы и красиво отображать ответы на эти запросы. Без различного рода костылей тут, по-моему, не обойтись.

                Возможно, Вам поможет решение от Амперки, которое послужило к моим статьям (этой и предыдущей): «Отображаем данные из Serial в Chrome Application»
                Там браузер (правда только Chrome) самостоятельно обращается к COM-порту на той же самой машине и выводит строки из него на страницу.

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