python

Перенаправление данных из COM-порта в Web

  • пятница, 14 августа 2015 г. в 02:11:28
http://habrahabr.ru/post/264663/

Недавно на хабре была статья «Отображаем данные из Serial в Chrome Application» о том, как красиво представить данные, отправляемые Arduin-кой в Serial. По-моему, ребята предложили очень красивое решение, которое с одной стороны выглядит достаточно простым, а с другой позволяет получить прекрасный результат с минимумом усилий.

В комментариях к статье было высказано сожаление о том, что такое решение не заработает под Firefox-ом и высказана идея, что «можно еще написать простенький веб-сервер с выдачей html на основе этой штуки». Меня эта идея «зацепила», быстрый поиск в google готового решения не выдал, и я решил реализовать идею сам. И вот, что из этого вышло.

Предупреждение! Предлагаемое решение ни в коем случае нельзя рассматривать как законченное. В отличие от Serial Projector от Амперки — это концепт, демонстрация возможного подхода, работающий прототип и не более того.

Некоторое время назад я делал проект, в котором использовал встроенные в Android-смартфон акселерометры для управления сервами, подключёнными к Arduino. Тогда для этих целей я воспользовался проектами Scripting Layer for Android (SL4A) и RemoteSensors. Оказывается, в стандартную библиотеку python-а входит пакет BaseHTTPServer, с помощью которого поднять веб-сервис на питоне — это задача на пару строчек кода.

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

После недолгого гугления возник вот такой скетч для ардуинки:

// source: https://code.google.com/p/tinkerit/wiki/SecretThermometer

long readTemp() {
  long result;
  // Read temperature sensor against 1.1V reference
  ADMUX = _BV(REFS1) | _BV(REFS0) | _BV(MUX3);
  delay(2); // Wait for Vref to settle
  ADCSRA |= _BV(ADSC); // Convert
  while (bit_is_set(ADCSRA,ADSC));
  result = ADCL;
  result |= ADCH<<8;
  result = (result - 125) * 1075;
  return result;
}

void setup() {
  Serial.begin(115200);
}

int count = 0;

void loop() {
  String s = String(count++, DEC) + ": " + String( readTemp(), DEC );
  Serial.println(s)
  delay(1000);
}

Этот скетч открывает COM-порт, настраивает его на скорость 115200 бод и затем каждую секунду пишет в него текущее значение встроенного термометра. (Не спрашивайте меня, в каких единицах выдаётся температура — для описываемой задачи это не важно). Поскольку значение меняется не очень активно, для лучшей видимости изменения данных перед температурой выводится номер строки.

Для проверки того, что веб-сервер будет отдавать наружу только целые строки, а не их части по мере чтения из COM-порта, строка
  Serial.println(s)

была заменена на
  for(int i=0; i < s.length(); i++ ){
    Serial.print( s.charAt(i) );
    delay( 200 );
  }
  Serial.println("");

т.е. сформированная строка выводится в последовательный порт не целиком, а посимвольно, с паузами в 200 мс.

Для начала был написан совсем простенький прототип веб-сервера (ниже он разобран по частям):
# -*- coding: utf-8 -*-

#-- based on: https://raw.githubusercontent.com/Jonty/RemoteSensors/master/remoteSensors.py

SERIAL_PORT_NAME = 'COM6'
SERIAL_PORT_SPEED = 115200

WEB_SERVER_PORT = 8000

import time, BaseHTTPServer, urlparse
import serial

ser = None

def main():

    global ser
    
    httpd = BaseHTTPServer.HTTPServer(("", WEB_SERVER_PORT), Handler)

    #-- workaround for getting IP address at which serving
    import socket
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.connect(('google.co.uk', 80))
    sData = s.getsockname()

    print "Serving at '%s:%s'" % (sData[0], WEB_SERVER_PORT)
    
    ser = serial.Serial(SERIAL_PORT_NAME, SERIAL_PORT_SPEED, timeout=0)

    
    httpd.serve_forever()

    
class Handler(BaseHTTPServer.BaseHTTPRequestHandler):

    # Disable logging DNS lookups
    def address_string(self):
        return str(self.client_address[0])

    def do_GET(self):

        self.send_response(200)
        self.send_header("Content-type", "application/x-javascript; charset=utf-8")
        self.end_headers()

        try:
            while True:
                
                new_serial_line = get_full_line_from_serial()
                if new_serial_line is not None:
                
                    self.wfile.write(new_serial_line)
                    self.wfile.write("\n")
                    self.wfile.flush()


        except socket.error, e:
            print "Client disconnected.\n"
            


captured = ''
def get_full_line_from_serial():
    """ returns full line from serial or None 
        Uses global variables 'ser' and 'captured'
    """
    global captured
    part = ser.readline()
    if part:
        captured += part
        parts = captured.split('\n', 1);
        if len(parts) == 2:
            captured = parts[1]
            return parts[0]
            
    return None
    
    
if __name__ == '__main__':
    main()


Разберём скрипт по частям.

Поскольку это прототип, то все основные параметры работы (имя COM-порта, его скорость, а также номер TCP-порта, на котором будет работать веб-сервер) указаны прямо в исходном тексте:
SERIAL_PORT_NAME = 'COM6'
SERIAL_PORT_SPEED = 115200

WEB_SERVER_PORT = 8000

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

В данном же случае пользователям Windows надо в диспетчере устройств узнать имя COM-порта, к которому подключена Arduin-ка. У меня это был 'COM6'. Пользователям других операционок надо использовать средства своих ОС. У меня совсем нет опыта работы с MacOS и в Linux-е я с COM-портами тоже не работал, но там, скорее всего, это будет что-нибудь типа "/dev/ttySn".

Далее идёт определение глобальной переменной, к которой будет привязан экземпляр класса Serial, отвечающего в питоне за работу с COM-портом:
ser = None

В строчке
httpd = BaseHTTPServer.HTTPServer(("", WEB_SERVER_PORT), Handler)

создаётся веб-сервер, который будет слушать запросы на заданном порту WEB_SERVER_PORT. А обрабатывать эти запросы будет экземпляр класса Handler, описанный ниже.

Следующие строки — это небольшой «хак», позволяющий вывести IP-адрес, на котором собственно работает запущенный веб-сервер:
    #-- workaround for getting IP address at which serving
    import socket
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.connect(('google.co.uk', 80))
    sData = s.getsockname()

    print "Serving at '%s:%s'" % (sData[0], WEB_SERVER_PORT)

Насколько я понял, не существует иного способа узнать этот IP. А как без этого знания мы будем обращаться из браузера к нашему серверу?

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

Чуть ниже происходит открытие COM-порта и собственно запуск веб-сервера:
    ser = serial.Serial(SERIAL_PORT_NAME, SERIAL_PORT_SPEED, timeout=0)
 
    httpd.serve_forever()

Затем следует описание класса, который отвечает за обработку полученных запущенным веб-сервером запросов:
class Handler(BaseHTTPServer.BaseHTTPRequestHandler):

Это наследник встроенного в модуль BaseHTTPServer класса, в котором достаточно переопределить только метод do_GET

Поскольку, это ещё пока прототип, то сервер будет «рад» любому запросу — какой бы URL у него не запросили, он будет отдавать клиенту все данные читаемые из COM-порта. Поэтому в Handler.do_GET он сразу отвечает кодом успеха и нужными заголовками:
        self.send_response(200)
        self.send_header("Content-type", "application/x-javascript; charset=utf-8")
        self.end_headers()

после чего запускается бесконечный цикл, в котором происходит попытка чтения целой строчки из COM-порта и, если эта попытка оказалась успешной, передача её веб-клиенту:
            while True:
                
                new_serial_line = get_full_line_from_serial()
                if new_serial_line is not None:
                
                    self.wfile.write(new_serial_line)
                    self.wfile.write("\n")
                    self.wfile.flush()

В проекте, который был взят за основу, этот бесконечный цикл был «завёрнут» в блок try … except, с помощью которого предполагалось аккуратно обрабатывать разрыв соединения. Возможно, в Android-е (базовый проект разрабатывался под него) это и работает нормально, но у меня под Windows XP так не вышло — при разрыве соединения возникало какое-то другое исключение, которое я так и не научился перехватывать. Но, к счастью, это не мешало веб-серверу работать нормально и принимать следующие запросы.

Функция получения целой строки из COM-порта работает по тому же принципу, что и у создателей Serial Projector:
  • есть некоторый глобальный буфер, в котором хранится всё, что прочитано из COM-порта
  • при каждом обращении к функции она пытается прочитать что-нибудь из COM-порта
  • если ей это удаётся, то
    • она добавляет только что прочитанное к указанному глобальному буферу
    • пытается поделить глобальный буфер максимум на две части символом конца строки
    • если и это ей удаётся, то первую часть она возвращает в вызвавшую процедуру, а вторую часть использует в качестве нового значения глобального буфера

  • если в COM-порту нет новых данных или не найден символ конца строки, то функция возвращает None:

captured = ''
def get_full_line_from_serial():
    """ returns full line from serial or None 
        Uses global variables 'ser' and 'captured'
    """
    global captured
    part = ser.readline()
    if part:
        captured += part
        parts = captured.split('\n', 1);
        if len(parts) == 2:
            captured = parts[1]
            return parts[0]
            
    return None

В результате получилось так:
Видно, что в браузере появляются строчки, читаемые из COM-порта. Я ничего не понимаю в веб-фронтенде: JavaScript, Ajax, CSS и DOM — это для меня тёмный лес. Но мне кажется, что для программистов, создающих веб-интерфейсы, этого должно быть вполне достаточно, чтобы преобразовать этот вывод в такую же красивую картинку, что выдаёт Serial Projector от Амперки. По-моему, задача сводится к тому, чтобы создать javascript-сценарий, который обращается к веб-серверу, читает из него поток и последнюю прочитанную строчку выводит в нужное место веб-страницы.

На всякий случай я решил подстраховаться и попытался сделать первое приближение своими силами. Не очень глубокий поиск в гугле подсказал, что вообще-то для таких целей, по крайней мере, раньше использовали WebSockets или Server-Sent Events. Я нашел, как мне показалось, неплохой учебник по использованию Server-Sent Events и решил использовать эту технологию.

Примечание! Похоже, это не самое лучшее решение, потому что эта технология не заработала ни в Internet Explorer 8, ни в браузере, встроенном в Android 2.3.5. Но она заработала хотя бы в Firefox 39.0, поэтому я не стал «копать» дальше.

Почитав указанный учебник, а также ещё один на русском языке, я взял за основу проект simpl.info/eventsource.

С точки зрения питоновского скрипта изменения под Server-Sent Events совершенно незначительные:
  • надо заменить тип отдаваемых клиенту данных:
    строчку
    		        self.send_header("Content-type", "application/x-javascript; charset=utf-8")
    

    заменить на
    		        self.send_header("Content-type", "text/event-stream")
    

  • а также перед прочитанной из COM-порта строчкой вставить префикс «data: » и добавить ещё один символ перевода строки:
    строки
    	                   self.wfile.write(new_serial_line)
    	                   self.wfile.write("\n")
    

    заменить на
    	                    self.wfile.write('data: ' + new_serial_line)
    	                    self.wfile.write("\n\n")
    



Всё остальное могло бы, наверное, остаться без изменений, но…

Сперва я создал файл index.html вот такого содержания:
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
</head>
<body>
  <h1>Заголовок</h1>
  <p id="data"></p>
  <script>
    var dataDiv = document.querySelector('#data');
    var source = new EventSource('http://192.168.1.207:8000/')
    source.onmessage = function(e) {
      dataDiv.innerHTML = e.data;
    };
  </script>
</body>
</html>

Самые интересные в нём — это строка
  <p id="data"></p>

которая формирует место для вывода очередной строчки из COM-порта, и javascript-сценарий
  <script>
    var dataDiv = document.querySelector('#data');
    var source = new EventSource('http://192.168.1.207:8000/')
    source.onmessage = function(e) {
      dataDiv.innerHTML = e.data;
    };
  </script>

который собственно и занимается чтением потока из веб-сервера и выводом прочитанной информации в указанное место.

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

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

Поменять, разумеется, надо только класс-обработчик запросов Handler:
class Handler(BaseHTTPServer.BaseHTTPRequestHandler):

    # Disable logging DNS lookups
    def address_string(self):
        return str(self.client_address[0])

    def do_GET(self):

        if self.path == '/' or self.path == '/index.html':
            self.process_index()
        elif self.path == '/get_serial':
            self.process_get_serial()
        else:
            self.process_unknown()
        

    def process_index(self):            
    
        self.send_response(200)
        self.send_header("Content-type", "text/html; charset=utf-8")
        self.end_headers()

        self.wfile.write(open('index.html').read())
        self.wfile.write("\n\n")
        self.wfile.flush()

        
    def process_get_serial(self):

        self.send_response(200)
        self.send_header("Content-type", "text/event-stream")
        self.end_headers()

        try:
            while True:
                
                new_serial_line = get_full_line_from_serial()
                if new_serial_line is not None:
                
                    self.wfile.write('data: ' + new_serial_line)
                    self.wfile.write("\n\n")
                    self.wfile.flush()


        except socket.error, e:
            print "Client disconnected.\n"


    def process_unknown(self):
        self.send_response(404)

В данном варианте предполагается, что веб-сервер будет отвечать только на два запроса: '/index.html' (отдавая html-код страницы) и '/get_serial' (отдавая бесконечный поток строк, считываемых из COM-порта). На все остальные запросы он будет отвечать кодом 404.

Поскольку index.html отдаётся питоновским веб-сервером, то его можно слегка изменить, указав вместо абсолютного адреса потока строк из COM-порта относительный:
строку
    var source = new EventSource('http://192.168.1.207:8000/')

заменить на
    var source = new EventSource('/get_serial')

В итоге получилось вот так:

На этом я решил остановиться. Как мне кажется, оформить страницу красиво — это уже должно быть совсем просто. Но я не владею ни HTML, ни CSS, поэтому пусть это сделает кто-нибудь другой. Я видел свою задачу в том, чтобы показать, что сделать веб-сервис, отдающий данные из COM-порта, вроде бы, совсем не сложно.

Все исходники можно взять на гитхабе.

Ещё раз повторюсь: представленный код — это не законченное решение, которое можно «пускать в продакшен». Это только прототип, который показывает принципиальный подход к решению задачи.

Над чем тут ещё можно поработать:
  • во-первых, чтение данных из COM-порта в питоновском скрипте сделано очень «топорно» — по сути дела происходит постоянный поллинг «а нет ли чего свеженького?». Такой подход, разумеется, нагружает процессор и одно ядро на моём компьютере занято на 100%.
    В качестве решения можно использовать блокирующее чтение с таймаутом. Для этого достаточно при открытии COM-порта качестве таймаута указать ненулевое значение (в секундах), например:
    	    ser = serial.Serial(SERIAL_PORT_NAME, SERIAL_PORT_SPEED, timeout=0.03)
    

    Кроме того, в описании модуля pySerial есть три примера создания моста: «TCP/IP — serial bridge», «Single-port TCP/IP — serial bridge (RFC 2217)» и «Multi-port TCP/IP — serial bridge (RFC 2217)» — можно посмотреть, как подобные задачи решают профессионалы.
  • во-вторых, данные может получать только один клиент. До тех пор, пока страница не будет закрыта на первом клиенте, нельзя подключиться к этому серверу и получать значения на втором компьютере. С одной стороны, это, наверное, правильно: COM-порт один, а потребителей несколько — кому из них отдавать прочитанную строчку? Если вы считаете, что ответом на данный вопрос должно быть «всем», то вот мои мысли по этому поводу. Как мне кажется, вопрос нельзя решить только использованием «честного» многопоточного веб-сервера (например, какого-нибудь Tornado или Flask), который может одновременно обслуживать запросы нескольких веб-клиентов. Потому что вы не можете из каждого потока открывать COM-порт и выполнять чтение из него — в этом случае данные из COM-порта будут уходить только одному потоку/процессу. Поэтому, по-моему, надо разбить серверную часть на две части:
    • zmq-сервер, который работает с COM-портом, читает из него строки и рассылает их через PUB-сокет всем заинтересованным потребителям
    • питоновский веб-сервер вместо подключения к COM-порту подключается к zmq-серверу и получает данные от него

    Если вы не знакомы с библиотекой ZMQ (ZeroMQ), то вместо неё можно воспользоваться обычными TCP/IP или UDP-сокетами, но я бы настоятельно рекомендовал познакомиться с ZMQ, потому что эта библиотека очень сильно облегчает решение подобных задач. Мне кажется, что с помощью ZMQ решение уложится максимум в 20 строк. (Не могу удержаться, чтобы не написать: даже если вы не планируете решать описанную задачу, но ваша работа связана с мультипоточным/мультипроцессным программированием с обменом данными между потоками/процессами, присмотритесь к этой библиотеке — возможно, это то, о чём вы так давно мечтали)
  • поток данных пока однонаправленный — из COM-порта в веб-браузер. Вы пока не можете из браузера послать данные в Arduino. Сдаётся мне, что эта задача тоже не очень сложная и она, в отличии от предыдущей, может решиться только
    • использованием многопоточного сервера
    • доработкой метода Handler.do_GET таким образом, чтобы он воспринимал GET-запросы с параметрами и значения определённых из них отправлял в COM-порт

    По-моему, при желании написать полноценный аналог встроенного в Arduino IDE монитора последовательного порта на основе веб-технологий — не так уж и сложно. Лично для себя я вижу сложность только в создании нормального фронтенда.
  • через браузер пока нельзя задать имя COM-порта и параметры его работы. С одной стороны это кажется логичным: откуда пользователь на другой стороне нашей планеты может знать, к какому именно COM-порту и на какой скорости подключена ардуина? Зато это точно знает питоновский веб-сервер, запущенный на том же самом компьютере. Но если всё-таки желательно дать пользователю возможность менять имя COM-порта или параметры его работы, то опять-таки это запросто решается доработкой метода Handler.do_GET
  • для запуска сервера требуется установить python. Это вообщем-то не сложно, но, если по каким-то причинам этого делать нельзя или не хочется, то на помощь может придти pyInstaller. С его помощью питоновский скрипт можно скомпоновать в один исполнимый файл (в случае Windows — в .exe), который просто копировать на компьютер, к которому подключена ардуинка.
    Возможно, лучшим решением было бы использовать в этом случае язык Go. Насколько я знаю, в нём задача создания файла для «дистрибуции» решена лучше.

В заключение: может возникнуть вопрос: «а не проще ли эту задачу решать через какое-нибудь готовое облако?». Почему бы не публиковать читаемые из COM-порта данные в облаке, а на клиентах просто обращаться к соответствующему сервису в облаке? Наверное, такое решение тоже имеет право на существование, но перед применением такого решения надо ответить на следующие вопросы:
  • существуют ли готовые веб-сервисы, которые позволяют публиковать данные с нужной мне скоростью/частотой? Существуют ли среди них бесплатные или готовы ли вы платить соответствующие деньги?
  • готовы ли вы к тому, что в случае падения облака или коннекта к нему, вы останетесь без данных
  • не смущает ли вас то, что для того, чтобы передать данные из одной комнаты в другую, они два раза пересекут океан или полконтинента?