Несколько недель назад я, развлечения ради, собрал простенькую роботизированную руку (а-ля манипулятор) и решил прикрутить к ней управление со смартфона через Bluetooth. Опыта в разработке нативных мобильных приложений у меня пока нет, с Apache Cordova я уже знаком, а вот задействовать Web Bluetooth API было бы интересно, приправив фишками Progressive Web Apps.

Картинка Для Привлечения Внимания
Картинка Для Привлечения Внимания, ведущая под кат


На первый взгляд может показаться, что статей по ключевым словам достаточно: есть спецификация Web Bluetooth, подробная статья в блоге Google Developers с примерами, есть подробный разбор Bluetooth Low Energy, примеры реверс-инжиниринга протоколов различных BLE устройств и даже моргания «умными» лампочками и получения данных от фитнес-браслетов прямо из браузера — что может пойти не так?

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

Оглавление



Проблема


Итак, вы собрали устройство, решили дооснастить его Bluetooth модулем и получить к нему доступ из браузера. Далее по тексту в качестве примера будет картинка подключения Bluetooth модуля к Arduino Uno, поэтому пусть будет устройство на базе Arduino, хотя, конечно же, нет никакой принципиальной разницы, используете ли вы STM, Raspberry, ESP8266 или что-либо ещё. Важно то, что ваш контроллер будет работать с Bluetooth модулем по протоколу UART (подробнее на Geektimes или в Википедии).

Если вы, также как и я, уже прикрутили к вашему девайсу старый добрый Bluetooth модуль HC-05, закинули прошивку, запустили какой-нибудь из примеров Google и не можете понять почему браузер не обнаруживает ваше устройство, то спешу мне придётся вас разочаровать: Web Bluetooth поддерживает только «стандарт Bluetooth 4».

Именно это является причиной написания статьи, поскольку когда вы, также как и я, вернётесь довольным из ближайшего магазина с BLE модулем (HM-10, к примеру) наперевес, то обнаружите, что работает он совсем по-другому и, самое главное, не поддерживает профиль последовательного порта (Serial Port Profile, SPP, подробнее в библиотеке Баумана), по которому вы привыкли беззаботно гонять байты туда-сюда.

Возможно, вы уже знакомы с концепцией Bluetooth Low Energy, в частности с профилем общих атрибутов (Generic Attribute Profile, GATT), но я попробую кратко пояснить то, что сейчас имеет значение для нас: вместо самодельного последовательного протокола, ваш девайс должен предоставлять набор прикладных «характеристик», которые сможет читать и/или изменять подключенное устройство.

Для примера возьмём роботизированную руку: она двигается в пространстве по трём координатам (числа X, Y, Z) и может открывать (0) или закрывать (1) клешню. Значит, нам нужно настроить BLE модуль на чтение и запись 4 характеристик, которые сможет узнать, прочитать и записать в них нужные значения подключенное устройство.

И это здорово, но вот незадача: обычные BLE модули «хобби» уровня, которые вы встретите в «соседнем магазине» или на Алиэкспресс: HM-10, JDY-08, AT-09, некий CC41-A, какой достался мне, или другие — не имеют возможности конфигурации каких-либо сервисов и характеристик.

Вместо этого они предоставляют лишь одну характеристику, которая как бы эмулирует последовательный порт, и всё, что вы в неё запишите, модуль отправит вашему контроллеру по TX, а всё, что вы отправите с контроллера на RX модуля, он перешлёт подключенному устройству. С ограничением в 20 байт, присущим любой BLE характеристике, кстати говоря.

Таким образом, несмотря на то, что Web Bluetooth ограничен использованием профиля общих атрибутов, нам для «бытового» использования фактически придётся сделать поверх него профиль последовательного порта.

Настройка Bluetooth Low Energy модуля


Для начала настроим BLE модуль, это не займёт много времени, если знать что да как. Так получилось, что у меня в руках оказался модуль CC41-A на чипе Texas Instruments CC2541, который в «соседнем магазине» обошелся мне в 340 р. Поэтому в качестве примера я опишу именно его конфигурацию, однако суть общая и для других модулей, использующих аналогичный чип.

Распиновка BLE модуля на примере HM-10

Распиновка BLE модуля на примере HM-10, кликабельно


Если у вас есть USB-TTL конвертер, то достаточно подсоединить к нему BLE модуль и вы получите прямой доступ к модулю с компьютера через COM порт. Обратите внимание на описание вашего модуля, возможно он работает с 3,3В логикой, поэтому на линиях TX-RX и RX-TX вам придётся использовать преобразователь уровня логических сигналов (voltage level shifter, на вкус и цвет на EasyElectronics). Модуль CC41-A, несмотря на то, что на нем написано «LEVEL:3.3V», замечательно справляется и с 5В логикой.

Подключение BLE модуля к USB-TTL конвертеру

Подключение BLE модуля к USB-TTL конвертеру, кликабельно


Вместо конвертера вы можете задействовать ваш контроллер, реализовав с его помощью простейший последовательный мост: всё что вы отправите в один последовательный порт, он передаст в другой, и наоборот. В случае с Arduino Uno вам придется использовать библиотеку SoftwareSerial:

Пример скетча для Arduino Uno
#include <SoftwareSerial.h>

SoftwareSerial SerialBt(2, 3);

void setup()
{
    Serial.begin(9600);
    SerialBt.begin(9600);
}

void loop()
{
    if (SerialBt.available()) {
        Serial.write(SerialBt.read());
    }

    if (Serial.available()) {
        SerialBt.write(Serial.read());
    }
}


Подключение BLE модуля к Arduino Uno

Подключение BLE модуля к Arduino Uno, кликабельно


Запустите терминальную программу (можно использовать Serial Monitor из Arduino IDE, я предпочитаю Bray's Terminal) и подключитесь к COM порту, на котором висит BLE модуль со стандартными настройками:

  • Baud rate: 9600
  • Data bits: 8
  • Parity: none
  • Stop bits: 1
  • Handshaking: none


В режиме ожидания модуль отвечает на AT команды, оканчивающиеся на возврат каретки и подачу строки (CR+LF, опция «Both NL & CR» в Serial Monitor). Некоторые BLE модули по умолчанию работают на другой скорости, например на 38400, некоторые модули входят в режим конфигурации после нажатия на кнопку, расположенную на их плате, некоторые модули не требуют, чтобы команды были в верхнем регистре — проверьте спецификации конкретно вашего модуля.

Окно терминальной программы в процессе конфигурации BLE модуля

Окно терминальной программы в процессе конфигурации BLE модуля,
кликабельно


Отправим команду «AT», чтобы проверить соединение. Модуль должен ответить «OK» — значит всё в порядке. На самом деле, достаточно убедиться, что модуль работает в ведомом режиме (slave mode), ожидая подключения ведущего устройства, UUID сервиса равен 0xFFE0, а UUID характеристики задан как 0xFFE1 — это нам понадобится в дальнейшем. Некоторые команды, которые работают с моим модулем:

  • AT — проверка работоспособности;
  • AT+HELP — вывод всех команд;
  • AT+DEFAULT — сброс настроек к заводским;
  • AT+RESET — soft перезагрузка;
  • AT+ROLE — вывод режима работы;
  • AT+ROLE0 — установка ведомого (slave) режима;
  • AT+NAME — вывод имени модуля;
  • AT+NAMESimon — установка имени модуля как Simon;
  • AT+PIN — вывод PIN кода (пароля) для сопряжения;
  • AT+PIN123456 — установка PIN кода как 123456;
  • AT+UUID — вывод UUID сервиса;
  • AT+UUID0xFFE0 — установка UUID сервиса как 0xFFE0;
  • AT+CHAR — вывод UUID характеристики;
  • AT+CHAR0xFFE1 — установка UUID характеристики как 0xFFE1.


Теперь можно попробовать подключиться к BLE модулю, например со страницы Characteristic Properties Sample, указав «0xFFE0» в качестве сервиса и «0xFFE1» в качестве характеристики. Или даже отправить что-нибудь из терминала в браузер на странице Notifications Sample.

Получение информации о характеристике и данных, отправленных из терминала

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


Создание веб-приложения


Разминка окончена, переходим к самому интересному!

Концепция


Предлагаю рассмотреть концепцию управления вашим девайсом. На обычной HTML странице в браузере вы создаете некий UI с разнообразными контролами, которые будут реализовать взаимодействие с вашим устройством.

Пример UI приложения для управления роботизированной рукой

Пример UI приложения для управления роботизированной рукой,
кликабельно


Если это, к примеру, роботизированная рука, двигающаяся по трём координатам и открывающая и закрывающая клешню, то это может быть три числовых слайдера или даже 2D поверхность, при нажатии на которую вычисляются значения X и Y, один слайдер для движения по оси Z и кнопка, открывающая или закрывающая клешню. Если это чайник, вы можете сделать кнопку «Вскипятить!». Если же это радиоуправляемая машинка, то вы можете сделать кнопки «вперед», «назад», «влево», «вправо», «включить/выключить фары», «подать сигнал» и тому подобные.

Общая картина того, что происходит под капотом

Общая картина того, что происходит под капотом, кликабельно


Повесив обработчики на нажатия или изменения состояний тех или иных элементов UI в JavaScript, вы формируете некоторое сообщение, которое отправляете посредством Web Bluetooth API на ваш девайс. BLE модуль получает сообщение, передает его контроллеру по UART, контроллер разбирает сообщение, предпринимает требуемые действия и может отправить ответ или ошибку в виде сообщения обратно BLE модулю по тому же UART, тогда модуль передаст его подключенному устройству, а вы получаете ответ с помощью JS в браузере.

Например, при нажатии на кнопку закрытия клешни, срабатывает обработчик кнопки onclick, который отправляет сообщение GRIPPER=CLOSE. Контроллер получает сообщение, понимает что от него требуется, закрывает клешню и отправляет назад сообщение GRIPPER=CLOSED. Обрабатывая это сообщение, мы в JS запоминаем состояние клешни и меняем текст на кнопке на «Открыть».

Задача


Создание HTML страницы (UI) и простая работа с JavaScript обработчиками событий это не такое уж сложное дело и здесь достаточно базовых знаний веб-технологий. Поэтому я предлагаю абстрагироваться от конкретного устройства и создать приложение-терминал, которое будет подключаться к вашему девайсу и обмениваться с ним сообщениями.

Также мы реализуем логирование процесса подключения к Bluetooth Low Energy устройству, переподключение в случае потери связи и обойдём ограничение длины BLE характеристики в 20 байт.

Ну и под конец превратим обычную HTML страницу в прогрессивное веб-приложение (про Progressive Web Apps на Google Developers, в Википедии — in English), которое можно будет установить на рабочий стол смартфона и использовать в условиях отсутствия интернета.

Получив возможность обмениваться сообщениями между HTML страницей и вашим устройством, стабильное соединение и простой API, заточить приложение под ваши нужды не составит большого труда.

Подготовка


Кроме любимой IDE вам понадобится работающий «девайс», который мы настроили ранее, это поможет в реальном времени получать и отправлять сообщения через терминальную программу на компьютере для тестирования приложения.

Web Bluetooth API доступен по умолчанию в Chrome 56+ и Opera 43+. В статье Google Developers также упомянуто, что в Linux нужно включить флаг chrome://flags/#enable-experimental-web-platform-features и перезагрузить браузер.

И последний немаловажный момент: веб-приложение должно открываться либо под HTTPS (можно использовать GitHub Pages), либо под http://localhost — таковы требования безопасности.

UI


Приложение будет состоять из одной HTML страницы index.html, одного файла стилей styles.css и одного main.js файла, в котором будет происходить вся магия.

Сделаем кнопку для подключения к устройству, кнопку отключения, div-контейнер для сообщений и форму отправки, состоящую из текстового поля и кнопки «Send»:

index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link href="styles.css" rel="stylesheet">
</head>
<body>

<button id="connect" type="button">Connect</button>

<button id="disconnect" type="button">Disconnect</button>

<div id="terminal"></div>

<form id="send-form">
    <input id="input" type="text">
    <button type="submit">Send</button>
</form>

<script src="main.js"></script>

</body>
</html>

В div-контейнер будем выводить лог подключения, входящие и исходящие сообщения в следующем виде:

<div id="terminal">
    <div>Подключение к устройству...</div>
    <div class="out">Исходящее сообщение</div>
    <div class="in">Входящее сообщение</div>
</div>

Чтобы не гадать какое сообщение откуда, разделим их по цветам в стилях:

styles.css
#terminal div {
    color: gray;
}

#terminal div.out {
    color: red;
}

#terminal div.in {
    color: blue;
}

Как видите, ничего особенного. Интерфейс готов :)

Обработчики событий


Дальнейшая работа будет происходить в main.js.

Получим ссылки на элементы UI, повесим обработчики на клик по кнопкам подключения и отключения и на отправку формы:

// Получение ссылок на элементы UI
let connectButton = document.getElementById('connect');
let disconnectButton = document.getElementById('disconnect');
let terminalContainer = document.getElementById('terminal');
let sendForm = document.getElementById('send-form');
let inputField = document.getElementById('input');

// Подключение к устройству при нажатии на кнопку Connect
connectButton.addEventListener('click', function() {
  connect();
});

// Отключение от устройства при нажатии на кнопку Disconnect
disconnectButton.addEventListener('click', function() {
  disconnect();
});

// Обработка события отправки формы
sendForm.addEventListener('submit', function(event) {
  event.preventDefault(); // Предотвратить отправку формы
  send(inputField.value); // Отправить содержимое текстового поля
  inputField.value = '';  // Обнулить текстовое поле
  inputField.focus();     // Вернуть фокус на текстовое поле
});

// Запустить выбор Bluetooth устройства и подключиться к выбранному
function connect() {
  //
}

// Отключиться от подключенного устройства
function disconnect() {
  //
}

// Отправить данные подключенному устройству
function send(data) {
  //
}

Подключение к устройству


Полный алгоритм подключения состоит из нескольких этапов:

  1. Запрос Bluetooth устройства: браузер запускает диалог поиска и выбора ближайшего устройства, пользователь осуществляет выбор, код приложения получает объект.
  2. Подключение к устройству из кода приложения:
    1. подключение к серверу профиля общих атрибутов (GATT Server),
    2. получение нужного сервиса,
    3. получение нужной характеристики.
  3. Включение уведомлений об изменении характеристики — необходимо, чтобы получать сообщения от вашего девайса.


Оформим в коде:

// Кэш объекта выбранного устройства
let deviceCache = null;

// Запустить выбор Bluetooth устройства и подключиться к выбранному
function connect() {
  return (deviceCache ? Promise.resolve(deviceCache) :
      requestBluetoothDevice()).
      then(device => connectDeviceAndCacheCharacteristic(device)).
      then(characteristic => startNotifications(characteristic)).
      catch(error => log(error));
}

// Запрос выбора Bluetooth устройства
function requestBluetoothDevice() {
  //
}

// Подключение к определенному устройству, получение сервиса и характеристики
function connectDeviceAndCacheCharacteristic(device) {
  //
}

// Включение получения уведомлений об изменении характеристики
function startNotifications(characteristic) {
  //
}

// Вывод в терминал
function log(data, type = '') {
  //
}

Мы реализовали в функции connect() Promise цепочку (цепочку функций, возвращающих Promise объекты), соответствующую этапам подключения.

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

В первой строчке тела функции connect() тернарный оператор немедленно создает выполненный Promise с объектом deviceCache, если тот не равен нулю, или обращается к функции запроса выбора Bluetooth устройства в противном случае. Таким образом, если пользователь уже подключился к устройству, то при последующем нажатии на кнопку «Connect», диалог выбора устройства не появится.

Если происходит ошибка на любом из этапов, мы выводим её в терминал с помощью функции log(), которую также реализуем позднее.

Запрос Bluetooth устройства


Чтобы запросить выбор Bluetooth устройства, необходимо вызвать функцию navigator.bluetooth.requestDevice() c объектом-конфигурацией в качестве обязательного аргумента, который описывает какие Bluetooth устройства нам интересны. Можно использовать фильтр по сервисам, по имени, можно принять все устройства, но используемый сервис всё равно необходимо указать, иначе браузер не предоставит доступ к нему.

// Запрос выбора Bluetooth устройства
function requestBluetoothDevice() {
  log('Requesting bluetooth device...');

  return navigator.bluetooth.requestDevice({
    filters: [{services: [0xFFE0]}],
  }).
      then(device => {
        log('"' + device.name + '" bluetooth device selected');
        deviceCache = device;

        return deviceCache;
      });
}

Мы же запрашиваем все устройства, предоставляющие сервис с UUID 0xFFE0, на использование которого конфигурировали BLE модуль. После выбора устройства пользователем, Promise выполняется с объектом устройства, который мы записываем в вышеупомянутый кэш и возвращаем далее.

Подключение к устройству, получение объектов сервиса и характеристики


// Кэш объекта характеристики
let characteristicCache = null;

// Подключение к определенному устройству, получение сервиса и характеристики
function connectDeviceAndCacheCharacteristic(device) {
  if (device.gatt.connected && characteristicCache) {
    return Promise.resolve(characteristicCache);
  }

  log('Connecting to GATT server...');

  return device.gatt.connect().
      then(server => {
        log('GATT server connected, getting service...');

        return server.getPrimaryService(0xFFE0);
      }).
      then(service => {
        log('Service found, getting characteristic...');

        return service.getCharacteristic(0xFFE1);
      }).
      then(characteristic => {
        log('Characteristic found');
        characteristicCache = characteristic;

        return characteristicCache;
      });
}

Выполняем простую Promise цепочку, которая говорит сама за себя. Переменная characteristicCache — по аналогии с deviceCache — сохраняет полученный объект характеристики, он потребуется для записи в него данных, то есть для отправки сообщения из браузера устройству.

В функциях getPrimaryService() и getCharacteristic() в качестве аргументов используются UUID, на работу с которыми настроен BLE модуль.

Включение уведомлений об изменении характеристики


// Включение получения уведомлений об изменении характеристики
function startNotifications(characteristic) {
  log('Starting notifications...');

  return characteristic.startNotifications().
      then(() => {
        log('Notifications started');
      });
}

Достаточно обратиться к методу startNotifications() объекта характеристики, после чего повесить обработчик на событие изменения характеристики, но об этом позднее.

Вывод в терминал


Реализуем функцию вывода в терминал, чтобы прямо сейчас протестировать подключение к девайсу:

// Вывод в терминал
function log(data, type = '') {
  terminalContainer.insertAdjacentHTML('beforeend',
      '<div' + (type ? ' class="' + type + '"' : '') + '>' + data + '</div>');
}

С помощью метода insertAdjacentHTML() мы вставляем div с указанным в аргументе type классом в конец div-контейнера терминала — очень просто.

Тестирование


Откройте страницу в браузере, нажмите кнопку «Connect», после чего запустится диалог выбора устройства. Подключитесь к своему девайсу, в терминале появятся сообщения о процессе подключения.

Выбор устройства и подключение

Выбор устройства и подключение, кликабельно


Тут я столкнулся с парой подводных камней, о которых нигде не написано. Вывод сервисных сообщений помог диагностировать и устранить проблемы.

Первый заключается в том, что когда я подключался к устройству с телефона, к которому привязан Mi Band, также работащий по BLE и находящийся в непосредственной близости, подключение устанавливалось крайне редко, а если и устанавливалось, то практически сразу отваливалось. Такое происходило даже в нативных приложениях. Пробовал отнести Mi Band на расстояние — не помогло. Отвязывать браслет не стал, просто использую другой смартфон. Если у вас возникнут аналогичные проблемы, обратите внимание на устройства, которые параллельно общаются с вашим смартфоном.

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

Автоматическое переподключение


Для отслеживания разъединения, Web Bluetooth предлагает событие gattserverdisconnected, обработчик которого следует повесить на объект устройства. Наиболее логичным местом для этого является функция выбора устройства:

// Запрос выбора Bluetooth устройства
function requestBluetoothDevice() {
  log('Requesting bluetooth device...');

  return navigator.bluetooth.requestDevice({
    filters: [{services: [0xFFE0]}],
  }).
      then(device => {
        log('"' + device.name + '" bluetooth device selected');
        deviceCache = device;

        // Добавленная строка
        deviceCache.addEventListener('gattserverdisconnected',
            handleDisconnection);

        return deviceCache;
      });
}

// Обработчик разъединения
function handleDisconnection(event) {
  let device = event.target;

  log('"' + device.name +
      '" bluetooth device disconnected, trying to reconnect...');

  connectDeviceAndCacheCharacteristic(device).
      then(characteristic => startNotifications(characteristic)).
      catch(error => log(error));
}

Теперь, если вы подключитесь к устройству, а затем обесточите его, и Bluetooth соединение потеряется, браузер единоразово попробует подключиться заново:

Попытка переподключения

Попытка переподключения, кликабельно


Отключение от устройства


Перед отключением важно не забыть снять назначенный обработчик с события gattserverdisconnected, иначе браузер просто будет переподключаться:

// Отключиться от подключенного устройства
function disconnect() {
  if (deviceCache) {
    log('Disconnecting from "' + deviceCache.name + '" bluetooth device...');
    deviceCache.removeEventListener('gattserverdisconnected',
        handleDisconnection);

    if (deviceCache.gatt.connected) {
      deviceCache.gatt.disconnect();
      log('"' + deviceCache.name + '" bluetooth device disconnected');
    }
    else {
      log('"' + deviceCache.name +
          '" bluetooth device is already disconnected');
    }
  }

  characteristicCache = null;
  deviceCache = null;
}

Можно не обнулять deviceCache, тогда при последующем нажатии кнопки «Connect» диалог выбора устройства не будет появляться, подключаясь к прошлому устройству вместо этого.

Отключение

Отключение, кликабельно


Получение данных


Получение данных от устройства осуществляется асинхронно с помощью механизма уведомлений, которые возникают, когда изменяется значение BLE характеристики. Нам лишь нужно подписаться на соответствующие событие characteristicvaluechanged характеристики. Сделать это следует после включения уведомлений. Также будет правильно снять обработчик с характеристики при отключении устройства:

// Включение получения уведомлений об изменении характеристики
function startNotifications(characteristic) {
  log('Starting notifications...');

  return characteristic.startNotifications().
      then(() => {
        log('Notifications started');
        // Добавленная строка
        characteristic.addEventListener('characteristicvaluechanged',
            handleCharacteristicValueChanged);
      });
}

// Отключиться от подключенного устройства
function disconnect() {
  if (deviceCache) {
    log('Disconnecting from "' + deviceCache.name + '" bluetooth device...');
    deviceCache.removeEventListener('gattserverdisconnected',
        handleDisconnection);

    if (deviceCache.gatt.connected) {
      deviceCache.gatt.disconnect();
      log('"' + deviceCache.name + '" bluetooth device disconnected');
    }
    else {
      log('"' + deviceCache.name +
          '" bluetooth device is already disconnected');
    }
  }

  // Добавленное условие
  if (characteristicCache) {
    characteristicCache.removeEventListener('characteristicvaluechanged',
        handleCharacteristicValueChanged);
    characteristicCache = null;
  }

  deviceCache = null;
}

// Получение данных
function handleCharacteristicValueChanged(event) {
  let value = new TextDecoder().decode(event.target.value);
  log(value, 'in');
}

event.target.value — это объект DataView, содержащий ArrayBuffer, в котором находится сообщение от вашего девайса. Используя TextDecoder (MDN, in English only), мы перегоняем массив байтов в текст.

Отправка данных из терминала и получение в браузере

Отправка данных из терминала и получение в браузере, кликабельно


Тестирование показывает, что прием сообщений с устройства работает стабильно, с окончанием строки символами CR, LF или без них. Длинные сообщения доходят полностью, но разбиваются кратно 20 байтам.

Введение промежуточного буфера


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

Символом-разделителем будет логично сделать подачу строки (LF, \n). Также может быть нелишним удалить пробельные символы с начала и конца сообщения:

// Промежуточный буфер для входящих данных
let readBuffer = '';

// Получение данных
function handleCharacteristicValueChanged(event) {
  let value = new TextDecoder().decode(event.target.value);

  for (let c of value) {
    if (c === '\n') {
      let data = readBuffer.trim();
      readBuffer = '';

      if (data) {
        receive(data);
      }
    }
    else {
      readBuffer += c;
    }
  }
}

// Обработка полученных данных
function receive(data) {
  log(data, 'in');
}

При создании веб-приложения, специфичного для вашего девайса, вы сможете изменить функцию receive() под свои нужды, будучи уверенными в том, что работаете с цельным сообщением от устройства.

Отправка данных из терминала и получение в браузере после введения промежуточного буфера

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


Отправка данных


Отправка данных девайсу осуществляется путём записи значения в характеристику, а конкретнее вызовом метода writeValue() объекта характеристики с ArrayBuffer в качестве аргумента. Для преобразования строки в ArrayBuffer проще всего воспользоваться TextEncoder (MDN, in English only):

// Отправить данные подключенному устройству
function send(data) {
  data = String(data);

  if (!data || !characteristicCache) {
    return;
  }

  writeToCharacteristic(characteristicCache, data);
  log(data, 'out');
}

// Записать значение в характеристику
function writeToCharacteristic(characteristic, data) {
  characteristic.writeValue(new TextEncoder().encode(data));
}

На всякий случай приводим данные к строковому типу, используя глобальный объект String.

В такой реализации также будет действовать ограничение в 20 байт: всё, что выходит за рамки, просто будет обрезано. Поэтому в случае, если сообщение длиннее 20 байт, стоит разбить его на куски и последовательно отправить с некоторой задержкой:

// Отправить данные подключенному устройству
function send(data) {
  data = String(data);

  if (!data || !characteristicCache) {
    return;
  }

  data += '\n';

  if (data.length > 20) {
    let chunks = data.match(/(.|[\r\n]){1,20}/g);

    writeToCharacteristic(characteristicCache, chunks[0]);

    for (let i = 1; i < chunks.length; i++) {
      setTimeout(() => {
        writeToCharacteristic(characteristicCache, chunks[i]);
      }, i * 100);
    }
  }
  else {
    writeToCharacteristic(characteristicCache, data);
  }

  log(data, 'out');
}

Чтобы облегчить обработку сообщений на стороне контроллера, добавим в конец отправляемого сообщения символ подачи строки (\n).

Далее сообщение разбивается на куски с помощью регулярного выражения, корректно обрабатывающего символы возврата каретки (CR, \r) и подачи строки (LF, \n), после чего первая часть отправляется сразу, а на отправку других выставляются таймеры с задержкой, кратной 100 миллисекундам.

Обмен данными между терминалом и браузером

Обмен данными между терминалом и браузером, кликабельно


Работает! Мы получили полнофункциональный двусторонний обмен данными с устройством и на этом с JS всё.

Progressive Web App


Мы не можем заранее знать в каких условиях окажется ваш девайс, поэтому было бы неплохо иметь возможность работать с созданным веб-приложением без интернета. И здесь нам поможет концепция Progressive Web Apps (подробнее на Google Developers или в Википедии — in English): в двух словах это веб-сайты, которые выглядят для пользователя как обычные или мобильные приложения. С использованием технологий PWA при первом посещении веб-сайта мы сможем установить его как приложение на рабочий стол смартфона и работать с ним оффлайн.

Иконка


Иконка необходима для установки приложения на рабочий стол. Лично я использую realfavicongenerator.net — загрузите подходящее изображение и генератор предложит скорректировать иконки для разных устройств.

В разделе «Favicon for Android Chrome» рекомендую переключиться на вкладку «Assets» и выбрать «Create all documented icons», в противном случае Chrome сам будет генерировать иконку для рабочего стола из наиболее близких к нужному ему размеру, который может различаться на разных устройствах.

После окончания настройки нажмём кнопку «Generate», скачаем «Favicon package» и распакуем его рядом с веб-страницей. Также скопируем предложенный генератором код в <head>.

Манифест


Вместе с иконками генератор любезно предоставил нам заготовку манифестаmanifest.json:

{
  "name": "",
  "icons": [
    ...
  ],
  "theme_color": "#ffffff",
  "background_color": "#ffffff",
  "display": "standalone"
}

Укажите название вашего приложения в свойстве name и добавьте свойство short_name, содержащее сокращённое название, не более 12 символов.

В массиве icons уже перечислены все сгенерированные иконки, в свойстве display — режим отображения приложения. standalone означает, что веб-приложение будет запускаться без элементов UI браузера, максимально похожим на нативное приложение — то, что нам нужно.

Браузер окрасит свой тулбар в цвет theme_color, а background_color будет использоваться как фон для Splash screen при загрузке приложения. Изменяя theme_color в манифесте, не забудьте также изменить мета тег <meta name="theme-color" content="#ffffff">.

Также стоит добавить свойства start_url и scope равные ./, чтобы во-первых, при запуске приложения оно открывалось на главной странице, а во-вторых, ограничить область навигации веб-приложения текущей и вложенными страницами, что может быть полезно, если файлы приложения располагаются не в корневой директории веб-сайта.

Service Worker


Service Worker позволит нам закэшировать необходимые приложению файлы и использовать их в условиях отсутствия интернета. Быстро создать свой Service Worker нам поможет Service Worker Toolbox, достаточно скачать sw-toolbox.js и companion.js, расположить их рядом с index.html и добавить в конец <body>:

<script src="companion.js" data-service-worker="sw.js"></script>

После этого останется только добавить скрипт sw.js рядом с index.html, чтобы кэшировать необходимые файлы:

importScripts('sw-toolbox.js');

toolbox.precache([
  'companion.js',
  'index.html',
  'main.js',
  'styles.css',
]);

Теперь у нас не просто страничка, а самое настоящее прогрессивное веб-приложение:

Добавление приложения на рабочий стол и запуск

Добавление приложения на рабочий стол и запуск, кликабельно


Финальный тест

Финальный тест, кликабельно


Эпилог


Вот так легко и просто начать разрабатывать собственные Bluetooth Low Energy девайсы с бюджетными модулями и кроссплатформенными веб-приложениями, используя двусторонний канал общения между устройством и браузером.

Теперь вы можете изменить UI на специфичный для вашего девайса, добавить необходимые обработчики, имплементировать свой протокол общения с устройством и… включать чайник на кухне, нажимая кнопку в приложении на смартфоне, например.

Конечный код файлов index.html, styles.css, main.js и sw.js доступен здесь.

Доработанное приложение веб-терминала вы можете запустить здесь: 1oginov.github.io/Web-Bluetooth-Terminal — или посмотреть как оно работает на YouTube.

На GitHub вы также сможете найти отдельно ES6 класс для последовательного общения с BLE модулями и, собственно, репозиторий веб-терминала.

UPD: Добавил опрос.

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


  1. oWart
    02.10.2017 17:21

    А есть вариант сделать то же самое под iOS?


    1. Qumbeez
      04.10.2017 13:11

      Браузера под iOs нет?


      1. oWart
        04.10.2017 13:31

        Поддерживает ли это родной браузер iOS и можно ли это собрать в самостоятельное приложение без необходимости доступа к интернету?


        1. loginov Автор
          05.10.2017 02:47

          Safari — нет. Однако в багрепорте есть ссылка на «самодельный браузер» с поддержкой Web Bluetooth.

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


          1. oWart
            05.10.2017 12:07

            Т.е запаковать в самостоятельное приложение не получится?


            1. loginov Автор
              05.10.2017 12:10

              А-ля нативное, имеете ввиду? Тогда есть вариант использовать Apache Cordova и собирать под конкретную платформу, но это совсем другая история.