Здравствуй, Хабр!
Хочу поделиться опытом создания небольшого приложения для 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)
AlNinyo
24.07.2015 22:22Здорово! Спасибо и за статью, и за софтинку. Я тут поэкспериментировал чуток. На самом деле, там же можно сразу кучу инфы выводить! Если всё запихать в «одну строку», а разбивку проводить с помощью HTML, то в софтинке замечательно выводится одновременно и температура, и давление (например, у меня от BMP180).
UPD: вопрос от идиота. А для Firefox такое не сделаете? Или можно эту софтинку и к FF прямо сейчас прикрутить?khim
24.07.2015 23:46Firefox не поддерживает WebSerial API. Вот тут обсуждение. Может лет через несколько чего и допилят.
AlNinyo
24.07.2015 23:51Спасибо. Придётся пользоваться Хромом для этой штуки, уж больно хороша, чертовка.
VEG
25.07.2015 10:29В Firefox можно сделать это же с использованием js-ctypes и нативных API. Да, придётся написать разные варианты кода под каждую поддерживаемую ОС, но всё же это возможно реализовать.
khim
25.07.2015 14:20+1Всё можно сделать,, но тут как бы проблема в том, что придётся решать проблемы с разными API и прочим. Да и потом — если уж писать нативный код, то возникает вопрос: а зачем тут вообще браузер?
istui
25.07.2015 10:39можно еще написать простенький веб-сервер с выдачей html на основе этой штуки, тогда результат будет работать в любом браузере (а при доступности сервера извне — и на любом компьютере)
AlNinyo
25.07.2015 10:55Боюсь, мне такое не осилить, ибо криворук и малообразован в программировании. Придётся ждать добрых людей, которые такое сделают для себя и поделятся с нами. А было бы очень полезно, т.к. мне пока так и не удалось завести ни ESP8266, ни Ethernet-модуль, из-за чего никак не передать данные со своей метеостанции в базу (MySQL) сайта :(
Tonna
25.07.2015 14:20+2Полезная софтина. Спасибо!
Уже попробовал :)muzhig
25.07.2015 21:40Есть очень крутой проект Codebender. Я очень рад, что появляются альтернативы. Так держать!
PoltoS
26.07.2015 00:49+1Крутая идея!
Но лучше бы парсить по regexp вывод и делать всю «графику» в самом Прожекторе. Тогда можно и готовые софтины использовать с Прожектором.VEG
26.07.2015 11:20Мне кажется, что парсить регулярками вывод — плохая идея. Лучше выводить все данные в JSON — с ним легко работать, и формат хорошо известен.
PoltoS
26.07.2015 16:58главное, не надо нагружать приложение ненужными тегами HTML и прочим. А ещё можно сразу сделать режимы графиков. Я бы вообще посмотрел в сторону любимого rrdtools или mrtg. Что-то похожее для Arduino выглядело бы очень наглядно.
Я об этом думаю, потому что мы делаем интересный подобный проект Z-Uno, где Прожектор реально может быть полезен для отладки.
istui
27.07.2015 15:02Да, память МК можно бы освободить от лишних строк, особенно, если используется микросхема меньше Amega 328
С другой стороны, для многих другие варианты будут более сложными, поэтому в программе лучше оставить поддержку как HTML/SVG, так и спец. формата для графики
zelyony
по-русски: COM-порт
nailxx Автор
Мне всегда казалось, что COM-порт это термин специфичный для операционок от MS. Да, широко распространённый, но всё же, завязанный на вендора термин. Поправьте, если ошибаюсь. Никаких упоминаний «COM» в Linux и Mac не найти, а интерфейс этот в них есть.
zelyony
термин устоялся еще с совка, макинтошей и линуксов тогда еще не было (макинтоши разве что у мажоров).
сам порт, по сути, пропал на десятилетие, его время прошло — его почти никуда не вставляли, кроме специфических железок.
с наплывом же в линукс (винда надоела и тд) и макось (надо забацать что-нибудь под iOS и тп) термин serial port… как бы сказать, уже и не знают как перевести.
по идее, это «последовательный порт», был еще параллельный… но писать последовательный в IT, где половина слов американизмы, и где программисты ленивы — скучно… «COM-порт» может быть не понятен только двухтысячникам, по-моему.
что же касается написания просто «Serial», даже без «порт», вот оно глаз точно режет «пересылаем данные из Serial в Ж.»
Jesting
Я, как программист написавший тонну кода для работы с различными железками, хочу сказать что не режет.
Spetros
Правильно — последовательный порт. На материнских платах он часто обозначается как COM-порт, термин связан с «железом», а не с ОС.
Garrett
всё же наверное Серийный порт
BeeZONE
Тогда уж последовательный.
bolk
а по-английски КОМ-port, видимо?
r00tGER
«communications port»
bolk
Мне табличку «сарказм» прикрепить к своему комментарию? Что в слове «СОМ» русского? Или следует читать «сом», как рыба?
fshp
Это по КДЕшному.
Dima_Sharihin
А RS-232 не подойдет?
Wedmer
А если там голый UART?
Lol4t0
Вот кстати UART видимо самый правильный темин для этого порта)
Wedmer
А что тогда делать с USART?
Lol4t0
А его вообще поддерживает кто-нибудь?
Wedmer
Я как то не задумывался, на сколько и кто поддерживает синхронный режим. Но в любом случае, почти любая операционка может работать с USART в асинхронном режиме.
ammaaim
Над UART может быть как RS-232 так и другие варианты (например RS-485). Так что такой вариант с физической точки зрения некорректен.
Lol4t0
Конечно может! И оба будут представлены в системе одинаковым образом — COM-портами в Windows и ttySX в Linux. В этом вся идея!
HomoLuden
У десктопов навряд ли последовательный порт следует называть UART. RS232 уместнее, т.к. хотя бы по напряжениям с UART не совместим.
UART (USART), если не ошибаюсь, чаще подразумевает напряжения 5В или TTL уровней.
Wedmer
Зависит от десктопов. С чипсета выходит тот же U(S)ART, а на нем может быть что угодно. Мы же должны ориентироваться на то, что видит софт. А он видит именно U(S)ART.