Здравствуй, Хабр!

Хочу поделиться опытом создания небольшого приложения для Google Chrome, которое взаимодействует с последовательным портом.

Краткая предыстория. Много раз мне хотелось, чтобы компьютер и подключенная к нему Arduino работали, как единая система, в которой микроконтроллер был бы посредником для общения с датчиками и исполнительными устройствами, а компьютер — большой удобной консолью.

Чтобы это произошло, на компьютере нужно либо по хакерски сидеть в консольном терминале, либо писать какую-нибудь небольшую GUI’шку. Даже самая примитивная GUI’шка требует каких-то непропорциональных усилий для своего создания. Нужно выбрать framework, реализовать кучу побочной GUI-логики, скомпилировать под всевозможные платформы, разобраться с зависимостями, запаковать .exe, проверить на маке и венде и т.д.

Давно слышал, что API для приложений Google Chrome даёт доступ к Serial. Захотел попробовать и заодно освоить создание Chrome-приложений как таковое. Получился Serial Projector — замена штатному Serial Monitor для Arduino IDE.

Суть проста до безобразия: приложение на весь экран отображает последнюю текстовую строку, пришедшую через последовательный порт. Это позволяет, например, выводить показания устройства крупно и няшно. Может оказаться полезным для всяких выставок, презентаций, инсталляций.

Подробности исходного кода и демонстрация работы — под катом.



Как устроено приложение


Давайте разберём Serial Projector. Все исходники есть на GitHub.

Итак, что же такое приложение для Google Chrome? По-большому счёту это просто динамическая web-страница. Точно такая же, как если бы вы её делали для своего сайта. Можно и нужно использовать всё те же JavaScript, CSS, HTML5, подключать сторонние библиотеки и примочки. Я использовал jQuery, Backbone.js, Underscore.js. Отличие заключается в том, что такая страница может использовать дополнительное «небезопасное» API для работы с компьютером пользователя. В частности, есть API для чтения и записи последовательного порта.

А ещё такое приложение может быть элементарно опубликовано в Chrome Web Store, а ваши пользователи смогут его элементарно установить.

Подобно тому, как это происходит на мобильниках, при установке приложение спросит подтверждение того, что вы доверяете ему доступ к тем или иным небезопасным API. Их перечень задаётся в файле-описании приложения manifest.json:

//...
  "permissions": [
      "serial",
      "fullscreen"
  ]
//...

Работа с последовательным портом


Самое интересное заключено в файле connection.js. Ниже приведён класс-модель для взаимодействия с serial-соединением. Не стоит вдумчиво читать сверху вниз, чтобы всё понять. Комментарии приведу ниже.

var RETRY_CONNECT_MS = 1000;

var Connection = Backbone.Model.extend({
    defaults: {
        connectionId: null,
        path: null,
        bitrate: 9600,
        autoConnect: undefined,
        ports: [],
        buffer: null,
        text: '...',
        error: '',
    },

    initialize: function() {
        chrome.serial.onReceive.addListener(this._onReceive.bind(this));
        chrome.serial.onReceiveError.addListener(this._onReceiveError.bind(this));
    },

    enumeratePorts: function() {
        var self = this;
        chrome.serial.getDevices(function(ports) {
            self.set('ports', ports);
            self._checkPath();
        });
    },

    hasPorts: function() {
        return this.get('ports').length > 0;
    },

    autoConnect: function(enable) {
        this.set('autoConnect', enable);
        if (enable) {
            this._tryConnect();
        } else {
            this._disconnect();
        }
    },

    _tryConnect: function() {
        if (!this.get('autoConnect')) {
            return;
        }

        var path = this.get('path');
        var bitrate = this.get('bitrate');

        if (path) {
            var self = this;
            chrome.serial.connect(path, {bitrate: bitrate}, function(connectionInfo) {
                self.set('buffer', new Uint8Array(0));
                self.set('connectionId', connectionInfo.connectionId);
            });
        } else {
            this.enumeratePorts();
            setTimeout(this._tryConnect.bind(this), RETRY_CONNECT_MS);
        }
    },

    _disconnect: function() {
        var cid = this.get('connectionId');
        if (!cid) {
            return;
        }

        var self = this;
        chrome.serial.disconnect(cid, function() {
            self.set('connectionId', null);
            self.enumeratePorts();
        });
    },

    _checkPath: function() {
        var path = this.get('path');
        var ports = this.get('ports');

        if (ports.length == 0) {
            this.set('path', null);
            return;
        }

        for (var i = 0; i < ports.length; ++i) {
            var port = ports[i];
            if (port.path == path) {
                return;
            }
        }

        this.set('path', ports[0].path);
    },

    _onReceive: function(receiveInfo) {
        var data = receiveInfo.data;
        data = new Uint8Array(data);
        this.set('buffer', catBuffers(this.get('buffer'), data));

        var lbr = findLineBreak(this.get('buffer'));
        if (lbr !== undefined) {
            var txt = this.get('buffer').slice(0, lbr);
            this.set('buffer', this.get('buffer').slice(lbr + 1));
            this.set('text', uintToString(txt));
        }
    },

    _onReceiveError: function(info) {
        this._disconnect();
        this.set('error', info.error);
        this.enumeratePorts();
    }
});

Непосредственное взаимодействие с Serial API можно заметить в трёх местах. Во первых, в конструкторе класса:

    initialize: function() {
        chrome.serial.onReceive.addListener(this._onReceive.bind(this));
        chrome.serial.onReceiveError.addListener(this._onReceiveError.bind(this));
    }

Здесь мы задаём традиционные для JS обработчики событий. При успешном получении порции данных мы будем вызывать метод _onReceive, а при любой ошибке _onReceiveError. Связи установлены, но подключения ещё нет. Для начала нужно выяснить, какие Serial-порты на компьютере пользователя сейчас видит Chrome:

    enumeratePorts: function() {
        var self = this;
        chrome.serial.getDevices(function(ports) {
            self.set('ports', ports);
            self._checkPath();
        });
    },

После опроса ОС будет вызвана функция, переданная в качестве параметра, с массивом найденных портов. Каждый элемент — это словарь, содержащий системный путь к порту, человекочитаемое имя, USB VID & PID железки.

Имея на руках системный путь, можно уже наконец подключиться:

            chrome.serial.connect(path, {bitrate: bitrate}, function(connectionInfo) {
                self.set('buffer', new Uint8Array(0));
                self.set('connectionId', connectionInfo.connectionId);
            });

После установления соединения, опять же, будет вызван предоставленный callback с параметрами соединения. В частности с connectionId, который понадобится для большинства операций по взаимодействию с портом.

Теперь рассмотрим процесс получения и разбора данных. Весь он умещается в одном методе класса:

    _onReceive: function(receiveInfo) {
        var data = receiveInfo.data;
        data = new Uint8Array(data);
        this.set('buffer', catBuffers(this.get('buffer'), data));

        var lbr = findLineBreak(this.get('buffer'));
        if (lbr !== undefined) {
            var txt = this.get('buffer').slice(0, lbr);
            this.set('buffer', this.get('buffer').slice(lbr + 1));
            this.set('text', uintToString(txt));
        }
    },

Каждый раз при получении порции данных, Chrome вызовет эту функцию и передаст в неё информацию о полученном пакете. Сами данные передаются в поле data. Оно имеет тип ArrayBuffer, с которым практически ничего нельзя делать напрямую. Это не строка, это не массив, это просто брикет из байтов «как есть».

Для того, чтобы брикет разобрать, нужно создать проекцию (view) ArrayBuffer’а, которая знает, как интерпретировать сырые данные. В случае с Arduino компилятором является AVR GCC, исходники пишутся в UTF-8, а следовательно данные, которые отправляются штатным Serial.println, передаются в виде UTF-8 строк.

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

Пара помощников


К моему удивлению проекции, в том числе наша Uint8Array, начали поддерживать slice’ing только в последних версиях Chrome. Для совместимости со старыми версиями, метод можно реализовать самостоятельно:

Uint8Array.prototype.slice = function(begin, end) {
    if (typeof begin === 'undefined') {
        begin = 0;
    }

    if (typeof end === 'undefined') {
        end = Math.max(this.length, begin);
    }

    var result = new Uint8Array(end - begin);
    for (var i = begin; i < end; ++i) {
        result[i - begin] = this[i];
    }

    return result;
}

Функций для склейки массивов и превращения их в штатные строки в коробке также не нашлись, поэтому:

function catBuffers(a, b) {
    var result = new Uint8Array(a.length + b.length);
    result.set(a);
    result.set(b, a.length);
    return result;
}

function uintToString(uintArray) {
    var encodedString = String.fromCharCode.apply(null, uintArray),
        decodedString = decodeURIComponent(escape(encodedString));
    return decodedString;
}

Код взаимодействия с HTML-содержимым страницы здесь приводить не буду, потому что он крайне прозаичен: пара троек обработчиков событий jQuery и Backbone-модели.

Итого


Итого, если вам нужно быстро состряпать консоль для своего железячного проекта и не беспокоиться о кросс-платформенности, о создании инсталлятора и о доставке обновлений и фиксов, Chrome Applications — шикарный выбор.

Надеюсь статья показала вам общую картину и у вас теперь есть, от чего оттолкнуться. А что в итоге получилось у нас, можете посмотреть в очередном видео на нашем YouTube-канале:

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


  1. zelyony
    24.07.2015 18:56
    +1

    по-русски: COM-порт


    1. nailxx Автор
      24.07.2015 19:03
      +6

      Мне всегда казалось, что COM-порт это термин специфичный для операционок от MS. Да, широко распространённый, но всё же, завязанный на вендора термин. Поправьте, если ошибаюсь. Никаких упоминаний «COM» в Linux и Mac не найти, а интерфейс этот в них есть.


      1. zelyony
        24.07.2015 20:27

        термин устоялся еще с совка, макинтошей и линуксов тогда еще не было (макинтоши разве что у мажоров).
        сам порт, по сути, пропал на десятилетие, его время прошло — его почти никуда не вставляли, кроме специфических железок.
        с наплывом же в линукс (винда надоела и тд) и макось (надо забацать что-нибудь под iOS и тп) термин serial port… как бы сказать, уже и не знают как перевести.
        по идее, это «последовательный порт», был еще параллельный… но писать последовательный в IT, где половина слов американизмы, и где программисты ленивы — скучно… «COM-порт» может быть не понятен только двухтысячникам, по-моему.
        что же касается написания просто «Serial», даже без «порт», вот оно глаз точно режет «пересылаем данные из Serial в Ж.»


        1. Jesting
          26.07.2015 11:26
          +1

          вот оно глаз точно режет «пересылаем данные из Serial в Ж.»

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


      1. Spetros
        24.07.2015 20:30
        +3

        Правильно — последовательный порт. На материнских платах он часто обозначается как COM-порт, термин связан с «железом», а не с ОС.


    1. Garrett
      24.07.2015 19:53
      -4

      всё же наверное Серийный порт


      1. BeeZONE
        24.07.2015 20:30
        +2

        Тогда уж последовательный.


    1. bolk
      24.07.2015 20:07
      -1

      а по-английски КОМ-port, видимо?


      1. r00tGER
        24.07.2015 21:32
        -1

        «communications port»


        1. bolk
          25.07.2015 09:22
          +6

          Мне табличку «сарказм» прикрепить к своему комментарию? Что в слове «СОМ» русского? Или следует читать «сом», как рыба?


      1. fshp
        25.07.2015 19:33
        +2

        Это по КДЕшному.


    1. Dima_Sharihin
      24.07.2015 21:44
      +2

      А RS-232 не подойдет?


      1. Wedmer
        24.07.2015 22:04

        А если там голый UART?


        1. Lol4t0
          24.07.2015 22:21

          Вот кстати UART видимо самый правильный темин для этого порта)


          1. Wedmer
            24.07.2015 22:26

            А что тогда делать с USART?


            1. Lol4t0
              25.07.2015 10:59

              А его вообще поддерживает кто-нибудь?


              1. Wedmer
                25.07.2015 12:48

                Я как то не задумывался, на сколько и кто поддерживает синхронный режим. Но в любом случае, почти любая операционка может работать с USART в асинхронном режиме.


          1. ammaaim
            25.07.2015 09:18
            +1

            Над UART может быть как RS-232 так и другие варианты (например RS-485). Так что такой вариант с физической точки зрения некорректен.


            1. Lol4t0
              25.07.2015 10:57
              +1

              Конечно может! И оба будут представлены в системе одинаковым образом — COM-портами в Windows и ttySX в Linux. В этом вся идея!


        1. HomoLuden
          27.07.2015 15:51

          У десктопов навряд ли последовательный порт следует называть UART. RS232 уместнее, т.к. хотя бы по напряжениям с UART не совместим.
          UART (USART), если не ошибаюсь, чаще подразумевает напряжения 5В или TTL уровней.


          1. Wedmer
            27.07.2015 16:37

            Зависит от десктопов. С чипсета выходит тот же U(S)ART, а на нем может быть что угодно. Мы же должны ориентироваться на то, что видит софт. А он видит именно U(S)ART.


  1. AlNinyo
    24.07.2015 22:22

    Здорово! Спасибо и за статью, и за софтинку. Я тут поэкспериментировал чуток. На самом деле, там же можно сразу кучу инфы выводить! Если всё запихать в «одну строку», а разбивку проводить с помощью HTML, то в софтинке замечательно выводится одновременно и температура, и давление (например, у меня от BMP180).

    UPD: вопрос от идиота. А для Firefox такое не сделаете? Или можно эту софтинку и к FF прямо сейчас прикрутить?


    1. khim
      24.07.2015 23:46

      Firefox не поддерживает WebSerial API. Вот тут обсуждение. Может лет через несколько чего и допилят.


      1. AlNinyo
        24.07.2015 23:51

        Спасибо. Придётся пользоваться Хромом для этой штуки, уж больно хороша, чертовка.


      1. VEG
        25.07.2015 10:29

        В Firefox можно сделать это же с использованием js-ctypes и нативных API. Да, придётся написать разные варианты кода под каждую поддерживаемую ОС, но всё же это возможно реализовать.


        1. khim
          25.07.2015 14:20
          +1

          Всё можно сделать,, но тут как бы проблема в том, что придётся решать проблемы с разными API и прочим. Да и потом — если уж писать нативный код, то возникает вопрос: а зачем тут вообще браузер?


    1. istui
      25.07.2015 10:39

      можно еще написать простенький веб-сервер с выдачей html на основе этой штуки, тогда результат будет работать в любом браузере (а при доступности сервера извне — и на любом компьютере)


      1. AlNinyo
        25.07.2015 10:55

        Боюсь, мне такое не осилить, ибо криворук и малообразован в программировании. Придётся ждать добрых людей, которые такое сделают для себя и поделятся с нами. А было бы очень полезно, т.к. мне пока так и не удалось завести ни ESP8266, ни Ethernet-модуль, из-за чего никак не передать данные со своей метеостанции в базу (MySQL) сайта :(


  1. Tonna
    25.07.2015 14:20
    +2

    Полезная софтина. Спасибо!

    Уже попробовал :)


    1. Tonna
      25.07.2015 14:28

      Жаль под android не работает. Тогда на планшет можно было бы выводить.


  1. muzhig
    25.07.2015 21:40

    Есть очень крутой проект Codebender. Я очень рад, что появляются альтернативы. Так держать!


  1. PoltoS
    26.07.2015 00:49
    +1

    Крутая идея!

    Но лучше бы парсить по regexp вывод и делать всю «графику» в самом Прожекторе. Тогда можно и готовые софтины использовать с Прожектором.


    1. VEG
      26.07.2015 11:20

      Мне кажется, что парсить регулярками вывод — плохая идея. Лучше выводить все данные в JSON — с ним легко работать, и формат хорошо известен.


      1. PoltoS
        26.07.2015 16:58

        главное, не надо нагружать приложение ненужными тегами HTML и прочим. А ещё можно сразу сделать режимы графиков. Я бы вообще посмотрел в сторону любимого rrdtools или mrtg. Что-то похожее для Arduino выглядело бы очень наглядно.

        Я об этом думаю, потому что мы делаем интересный подобный проект Z-Uno, где Прожектор реально может быть полезен для отладки.


    1. istui
      27.07.2015 15:02

      Да, память МК можно бы освободить от лишних строк, особенно, если используется микросхема меньше Amega 328
      С другой стороны, для многих другие варианты будут более сложными, поэтому в программе лучше оставить поддержку как HTML/SVG, так и спец. формата для графики