Сегодня делаем настольное приложение с графическим интерфейсом для управления роботом на Ардуине через последовательный порт. На языке JavaScript на платформе Electron с виджетами ReactJS+MaterialUI.

image


Теперь пульт управления для своего станочка с ЧПУ сделать не сложнее, чем написать сайтик.

Ранее:

— Часть 1: Консолька в роботе на Ардуине
— Часть 2: Управление роботом на Ардуино из приложения на Node.js

Главные ссылки

— Библиотека для робота: babbler_h
— Библиотека для Node.js: babbler-js
— Виджеты Babbler для Node.js+ReactJS+MaterialUI: babbler-js-material-ui
— Примеры приложений для babbler-js: babbler-js-demo

Ссылки на инструменты

— Последовательный порт в Node.js node-serialport: github.com/EmergingTechnologyAdvisors/node-serialport
— Платформа Electron: electron.atom.io
— ReactJS: facebook.github.io/react
— Виджеты (компоненты) MaterialUI для ReactJS: www.material-ui.com/#

Дополнительные ссылки

— Платформа NWJS: nwjs.io
— Другие виджеты для React:
github.com/facebook/react/wiki/Complementary-Tools#ui-components

Быстрый старт


1. Прошивайте в Ардуино скетч babbler_json_io.ino из предыдущей истории

Эта прошивка обменивается данными в формате JSON, умеет мигать лампочкой, содержит 4 команды: ping, help, ledon, ledoff

2. Качайте пульт управления:

git clone https://github.com/1i7/babbler-js-demo.git
cd babbler-js-demo/babbler-serial-react
npm install

3. Запускайте пульт управления:

./babbler-serial.sh

4. Нажимайте на кнопочки, мигайте лампочкой:

image

Структура проекта


Задача проекта — запустить библитеку Babbler.js для общения с роботом в окружении Electron (запускалка приложений JavaScript в отдельном окне, основана на коде Google Chrome), для графического интерфейса подключить ReactJS с виджетами MaterialUI. В общем, все не сложнее «Здравствуй мира» для перечисленных проектов, но в процессе собирания всех этих библиотек в одном приложении было выявлено несколько проблем и нюансов, поэтому в качестве шаблона новых проектов рекомендую брать за основу исходники этого примера.

Предварительные требования: иметь на компьютере установленными node.js, npm и (желательно) git.

Еще раз, качаем исходники и идем в проект babbler-js-demo/babbler-serial-react:

git clone https://github.com/1i7/babbler-js-demo.git
cd babbler-js-demo/babbler-serial-react

Файлы проекта

package.json — проект для npm (Node package manager): настройки проекта, список зависимостей.

Благодаря ему мы можем установить все зависимости, перечисленные в package.json (включая платформу Electron), одной командой внутри проекта:

npm install

main.js — главный файл для приложения Electron (взят из какого-то «здравствуй мир» для электрона).

Содержимое говорит само за себя. Единственное интересное место — команда, открывающая панель с инструментами разработки при старте приложения:

    // Открываем DevTools.
    mainWindow.webContents.openDevTools();

Рекомендую оставлять этот вызов при разработке приложения (открыть вручную через меню: View > Toggle Developer Tools) и удалять/комментировать при релизе.

index.html — содержимое главного экрана приложения Electron.

Т.к. для формирования графического интерфейса мы используем React, всё, что должно быть внутри body, — элемент div с id=«app-content»

<body>
    <div id="app-content"></div>
</body>

Этот файл мы править не будем, главный код будет дальше.

react-app-ui.js — главный файл приложения, здесь главное дерево виджетов для главного экрана (отправляется в index.html в div с id=«app-content»), весь пользовательский код, правим только его.

babbler-serial.sh — скрипт-запускалка приложения

#!/bin/sh
./node_modules/electron-prebuilt/dist/electron .

Дополнительные нюансы

Для заметки

Проблема с node-serialport и Electron

Библиотека node-serialport не захотела работать на последних версиях платформы Electron (при том, что на «голом» Node.js всё было прекрасно).

Не вдаваясь в подробности, отмечу, что проблему можно обойти, откатившись на старую версию Electron 1.1.3 или (как пишут в одном из обсуждений) пересобрать его из исходников.

sudo npm i -g electron-prebuilt@1.1.3

Эта же проблема наблюдается в платформе NWJS (альтернатива Electron), очевидно, что-то поломали в движке Хрома, на котором они все основаны.

Работающая версия Electron указана в зависимостях проекта в package.json, поэтому с демо-проектом всё ок.

Сообщения в баг-трекерах проектов:

> Node-serialport
> Electron
> NWJS

Возможно, в одном из следующих релизов проблема будет исправлена, в таком случае можно будет переключить Electron на более свежую версию.

Babel, синтаксис JSX и ES6

Приложение и компоненты ReactJS используют специальный синтаксис JSX — это HTML-подобный XML для описания структуры дерева элементов управления приложения прямо в коде JavaScript. Так же внутри приложения мы будем местами использовать расширенный синтаксис JavaScript ES6 (это набор всевозможных синтаксических конструкций языка, которые еще не вошли в стандарт JavaScript или вошли в него не так давно, поэтому пока не реализованы даже в самых свежих версиях браузеров). Сначала я хотел исключить конструкции ES6 (их все можно заменить на аналоги из «классического» JavaScript), чтобы не городить лишних конфигураций в проекте. Но потом сдался, т.к. многие примеры в интернете для ReactJS (и, в особенности, для MaterialUI) написаны с использованием синтаксиса ES6, и, в таком случае, мне бы пришлось всех их конвертировать в старый синтаксис JavaScript.

Для того, чтобы использовать нестандартный синтаксис на старом движке JavaScript, используют специальный инструмент — Babel. Он умеет на лету конвертировать нестандартные конструкции в их аналоги на обычном JavaScript, если в проекте задать правильные настройки. Здесь начинаются костыли и огород. В шаблоне проекта все необходимые настройки уже заданы, поэтому в подробности разбирать не буду, перечислю основные пункты:

— package.json должен содержать блок с настройками Babel:

"babel": { "presets": ["es2015", "react", "stage-1"] }

— Аналогичные настройки нужно указать в файле .babelrc, если в проект импортируете виджеты из каталогов за пределами текущего каталога (например: babbler-js-meterial-ui/src/.babelrc).

— Чтобы включить конвертацию Babel в блоках script (type=«text/babel») в HTML-файлах (у нас — index.html), в этом же файле нужно импортировать скрипт browser.min.js (локальная копия в проекте: babbler-serial-react/script/browser.min.js, чтобы не зависеть от интернета).

— Чтобы включить конвертацию Babel в отдельных js-файлах, нужно загрузить модуль 'babel-register', а сами js-файлы загружать через require('./react-app-ui.js'); (см всё тот же index.html).

Может быть через какое-то время новации ES6 перекочуют в основную ветку JavaScript в варианте Гугл Хрома (а оттуда — в Электрон) и часть этих подпорок можно будет выкинуть за ненадобностью.

Для работы виджетов MaterialUI в index.html необходимо загрузить модуль 'react-tap-event-plugin' и выполнить injectTapEventPlugin()

Смотрим исходники


Весь полезный пользовательский код расположен в одном файле — babbler-js-demo/babbler-serial-react/react-app-ui.js

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

Предварительные приготовления

Базовые объекты Реакта:

var React = require('react');
var ReactDOM = require('react-dom');

Виджеты MaterialUI — кнопки, иконки, табы, панельки:

// виджеты MaterialUI
import getMuiTheme from 'material-ui/styles/getMuiTheme';
import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider';

import Paper from 'material-ui/Paper';
import {Tabs, Tab} from 'material-ui/Tabs';
import Divider from 'material-ui/Divider';

import RaisedButton from 'material-ui/RaisedButton';

import FontIcon from 'material-ui/FontIcon';
import {red200, green200} from 'material-ui/styles/colors';

import Subheader from 'material-ui/Subheader';

Виджеты Babbler из проекта babbler-js-material-ui — взаимодействие с устройством:

— BabblerConnectionPanel — панель подключения: выбор устройств из выпадающего списка, кнопки подключиться/отключиться (в зависимости от статуса подключения)
— BabblerConnectionStatusIcon — иконка статуса подключения к устройству: отключены, подключаемся, подключены
— BabblerConnectionErrorSnackbar — всплывающая внизу экрана панелька, извещающая о разрыве соединения и других ошибках подключения
— BabblerDataFlow — полный лог в реальном времени: добавление команды в очередь, обмен данными с устройством и т.п.
— BabblerDebugPanel (пока определен не в библиотеке, а внутри тестового проекта) — панель отладки: отправка команд устройству вручную, кнопки help, ping, лог с BabblerDataFlow

// виджеты Babbler MaterialUI
import BabblerConnectionStatusIcon from 'babbler-js-material-ui/lib/BabblerConnectionStatusIcon';
import BabblerConnectionErrorSnackbar from 'babbler-js-material-ui/lib/BabblerConnectionErrorSnackbar';
import BabblerConnectionPanel from 'babbler-js-material-ui/lib/BabblerConnectionPanel';
import BabblerDataFlow from 'babbler-js-material-ui/lib/BabblerDataFlow';

import BabblerDebugPanel from './widgets/BabblerDebugPanel';

Babbler.js для связи с устройством:

// Babbler.js
import BabblerDevice from 'babbler-js';

Стиль для кнопочек:

const btnStyle = {
  margin: 12
};

Наконец, самая интересная часть — общение с роботом, панель управления лампочкой

Панель — обычный компонент React: кнопка «Включить лампочку», кнопка «Выключить лампочку», иконка статуса лампочки.

Про компоненты React следует знать:

— Компонент React работает как машина состояний (стейт-машина).
— Текущее состояние компонента определяют две группы значений: статические свойства this.props и динамические состояния this.state.
— Статические свойства this.props: передаются через параметры тега компонента при добавлении его на экран.
— Динамические состояния this.state: меняются в процессе выполнения приложения, устанавливаются в нужный момент при помощи this.setState.
— Изменения состояний через this.setState приводит к перерисовке компонента.
— Перерисовка компонента происходит в функции render, внешний вид зависит от значений this.props и this.state.
— Внешний вид компонента внутри render определяется через синтаксис React JSX.

В нашем случае:

— Объект BabblerDevice попадает в компонент через статический параметр this.props.babblerDevice.
— События babblerDevice меняют динамические состояния компонента (если подключены, делаем все кнопки активными, если не подключены — делаем неактивными).
— Кнопки «Включить лампочку» и «Выключить лампочку» отправляют команды ledon и ledoff устройству через babblerDevice.
— В случае получения положительного ответа «ok» меняют картинку статуса лампочки через запись значения свойства this.state.ledOn (true/false).

// Управление лампочкой
var BabblerLedControlPnl = React.createClass({
// http://www.material-ui.com/#/components/raised-button
// http://www.material-ui.com/#/components/subheader

    getInitialState: function() {
        return {
            deviceStatus: this.props.babblerDevice.deviceStatus(),
            ledOn: false
        };
    },
    
    componentDidMount: function() {
        // слушаем статус устройства
        this.deviceStatusListener = function(status) {
            this.setState({deviceStatus: status});
        }.bind(this);
        this.props.babblerDevice.on(BabblerDevice.Event.STATUS, this.deviceStatusListener);
    },
    
    componentWillUnmount: function() {
        // почистим слушателей
        this.props.babblerDevice.removeListener(BabblerDevice.Event.STATUS, this.deviceStatusListener);
    },
    
    render: function() {
        var connected = this.state.deviceStatus === BabblerDevice.Status.CONNECTED ? true : false;
        return (
            <div style={{textAlign: "center"}}>
                <div>
                    <RaisedButton label="Включить лампочку" onClick={this.cmdLedon} disabled={!connected} style={btnStyle} />
                    <RaisedButton label="Выключить лампочку" onClick={this.cmdLedoff} disabled={!connected} style={btnStyle} />
                </div>
                
                <FontIcon 
                    className="material-icons" 
                    style={{fontSize: 160, marginTop: 40}}
                    color={(this.state.ledOn ? green200 : red200)}
                >{(this.state.ledOn ? "sentiment_very_satisfied" : "sentiment_very_dissatisfied")}</FontIcon>
                     
            </div>
        );
    },
    
    cmdLedon: function() {
          this.props.babblerDevice.sendCmd("ledon", [],
              // onReply
              function(cmd, params, reply) {
                  if(reply == 'ok') {
                      this.setState({ledOn: true});
                  }
              }.bind(this),
              // onError
              function(cmd, params, err) {
                  console.log(cmd + (params.length > 0 ? " " + params : "") + ": " + err);
              }.bind(this)
          );
      }, 
      
      cmdLedoff: function() {
          this.props.babblerDevice.sendCmd("ledoff", [],
              // onReply
              function(cmd, params, reply) {
                  if(reply == 'ok') {
                      this.setState({ledOn: false});
                  }
              }.bind(this),
              // onError
              function(cmd, params, err) {
                  console.log(cmd + (params.length > 0 ? " " + params : "") + ": " + err);
              }.bind(this)
          );
      }
});

Главный экран приложения

Создаем устройство BabblerDevice для подключения к роботу:

// Устройство Babbler, подключенное к последовательному порту
var babblerDevice1 = new BabblerDevice();

Финальная верстка главного экрана приложения — синтаксис ReactJS JSX (HTML-подобный XML внутри кода JavaScript). Рисуем дерево элементов управления, отправляем в index.html в div с id='app-content'.

Здесь у нас панель подключения к устройству — полоска наверху, блоки общения с роботом — внутри табов.

// Контент приложения
ReactDOM.render(
    <MuiThemeProvider muiTheme={getMuiTheme()}>
      <div>
        <Paper>
            <BabblerConnectionPanel babblerDevice={babblerDevice1}/>
            <BabblerConnectionStatusIcon 
                babblerDevice={babblerDevice1} 
                iconSize={50}
                style={{position: "absolute", right: 0, marginRight: 14, marginTop: 5}} />
        </Paper>
        
        <Divider style={{marginTop: 20, marginBottom: 20}}/>
        
        <Tabs>
            <Tab label="Лампочки" >
                <BabblerLedControlPnl babblerDevice={babblerDevice1}/>
            </Tab>
            <Tab label="Отладка" >
                <BabblerDebugPanel babblerDevice={babblerDevice1}/>
            </Tab>
            <Tab label="Лог" >
                <BabblerDataFlow 
                    babblerDevice={babblerDevice1} 
                    reverseOrder={true}
                    maxItems={10000}
                    timestamp={true}
//                    filter={{ err: false, data: false }}
//                    filter={{ data: {queue: false} }}
//                    filter={{ err: {in: false, out: false, queue: false}, data: {in: false, out: false, queue: false} }}
                    style={{margin: 20}}/>
            </Tab>
        </Tabs>
        
        <BabblerConnectionErrorSnackbar babblerDevice={babblerDevice1}/>
      </div>
    </MuiThemeProvider>,
    document.getElementById('app-content')
);

Запускаем


./babbler-serial.sh

Выбираем устройство:

image

Подключаемся:

image

Ждем:

image

Включаем лампочку:

image

Выключаем лампочку:

image

Смотрим лог:

image

Шлем команды в ручном режиме:

image

image

Поделиться с друзьями
-->

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


  1. medvedevia
    27.11.2016 22:20
    +3

    Почему всё так заморочено? Я вот тоже сначала для своего ESP8266-робота решил через http-сервер команды слать, но понял, что парсить строки на таких медленных процессорах это не есть хорошо и решил попробовать отправлять и обрабатывать UDP-датаграммы. И как ни странно, это оказалось проще чем сначала сериализовать в строку, а потом десериализовать, и сам код стал проще, и работать стало быстрее (примерно в 17 раз). Вот проще же не куда, если твоя команда состоит из определенного количества байтов и каждый байт (ну или пару байтов) означает сигнал на определенный пин. В вашем случае тоже можно обрабатывать просто массива байтов, мне кажется все эти библиотеки они просто лишние.


  1. jMas
    27.11.2016 23:19

    В первом же посте я писал о том, что приведенное решение не для микроконтроллеров, потому как места часто недостаточно. Больше смахивает на just-for-fun концепт.


    Имхо, достаточно реализовать ответы на ping и небольшую обертку поверх js-библиотеки serialport чтобы получить более высокоуровневый итерфейс подписки на события connect, disconnect, data.


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


    1. sadr0b0t
      28.11.2016 10:30

      В первом же посте я писал о том, что приведенное решение не для микроконтроллеров, потому как места часто недостаточно. Больше смахивает на just-for-fun концепт.


      Только что ответил в тот тред. Базовый вариант прошивки без JSON не так уж сильно требователен к месту. Вариант с JSON жручий, да (думаю посмотреть еще другие варианты парсеров), но в мои контроллеры умещается и работает. В любом случае, я думаю, что ради единообразия кодовой базы и удобства разработки иногда можно пожертвовать местом на контроллере. Если требования проекта не позволяют, то, да, стоит подыскать другое решение.

      Имхо, достаточно реализовать ответы на ping и небольшую обертку поверх js-библиотеки serialport чтобы получить более высокоуровневый итерфейс подписки на события connect, disconnect, data.


      Ну, у меня, собственно, так и сделано

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


      Да, для примера с лампочкой следует добавить еще одну команду ledstatus.


  1. jMas
    27.11.2016 23:28

    Помимо всего прочего уже придумано множество протоколов, в частности можно обратить внимание на безпроводной MQTT, который горантирует доставку пакетов, и Firmata, который как раз создан чтобы связываться с девайсом по Serial (есть поддержка кучи языков, включая JavaScript).


    1. sadr0b0t
      29.11.2016 00:26

      Фирмата зеркалирует внутренний API ардуины во внешний мир (яваскрипт). По крайней мере, судя по их документации, это основная задача библиотеки. Мне этот подход не очень нравится, т.к. в этом случае бизнес-логика и алгоритмы должны полностью выполняться на клиенте, а платка только дает транзитный доступ к подключенным к ней железякам. Мне больше нравится вариант, когда робот живет относительно автономно, сам внутри себя выполняет алгоритмы, может быть, принимает какие-то решения, а внешний клиент по мере необходимости только присылает ему обобщенные задачи («иди туда», «делай то-то») и опрашивает статус («уже пришел?», «еще не сделал?»).


  1. Keroro
    28.11.2016 06:52

    Поставил, запускаю ./babbler-serial.sh, а там никаких кнопок, пустое окно. ЧЯДНТ?

    image


    1. sadr0b0t
      28.11.2016 10:04

      попробуйте открыть панель разработки View > Toggle Developer Tools, там будет вкладка Console и скорее всего сообщение о какой-нибудь ошибке


      1. Keroro
        28.11.2016 10:08

        Да, действительно, module.js:440 Uncaught Error: Cannot find module 'react/lib/EventPluginHub'
        Буду разбираться…


        1. sadr0b0t
          28.11.2016 10:45

          у меня этот файл на месте, скорее всего по какой-то причине не докачались модули во время npm install (разрыв соединения или закончилось место?). Я бы попробовал проверить доступное место на диске, удалить папку node_modules и выполнить npm install еще раз.


  1. fijj
    28.11.2016 11:19

    Не понимаю я этого электрона. И вроде бы завязан на веб разработке, но когда стояла задача сделать небольшое приложение для работы с com портом и передачей данных через websocket, для меня проще оказалось открыть visual studio и написать его на c# и winforms, и это учитывая что опыта не было вообще, разве что немного в visual basic.