Амперка corporate blog
JavaScript
Google Chrome
24 July 2015

Отображаем данные из Serial в Chrome Application



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

Хочу поделиться опытом создания небольшого приложения для 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-канале:


+19
28.5k 197
Comments 35
Top of the day