Используем Web Bluetooth API для подключения пульсометра и разрабатываем приложение используя Vue.js

Продолжаем обсуждать темы затронутые на You Gonna Love Frontend конференции. Эта статья вдохновлённая докладом Michaela Lehr. Видео с конференции будут доступны уже на этой недели, пока есть слайды. (Видео уже доступно)

-eajbawi-0acuwj8uwvwvbjdoea.jpeg

Michaela Lehr подключила вибратор к браузеру используя Web APIs, а именно Web Bluetooth API. Проснифериф трафик между приложением и вибратором, она установила, что посылаемые команды очень простые, например: vibrate: 5. Затем научив его вибрировать под звуки стонов из видео, которые она могла найти в интернете — достигла своих целей :)

У меня таких игрушек нет и конструкцией использование не предусмотрено, но есть пульсометр Polar H10, который использует Bluetooth для передачи данных. Собственно его я и решил «взламывать».


Взлома не будет

Первым делом, стоит понять каким образом подключить девайс к браузеру? Гуглим или яндексим в зависимости от ваших наклонностей: Web Bluetooth API, и по первой ссылке видим статью на эту тему.

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


r2owofhvye8qtyvviyb2qod1ugq.png

Меня это дико обескуражило, даже исходники есть. Что за времена пошли?


Подключаем устройство

Давайте создадим index.html с типичной разметкой:




  
  
  
  Document
















Поскольку мой пульсометр девайс сертифицированный хоть и ковался в китайских мастерских, но с соблюдением стандартов, его подключение и использование не должно вызвать каких либо сложностей. Существует такая вот вещь — Generic Attributes (GATT). Я сильно не вдавался в подробности, но если просто, то это своего рода спецификация которой следуют Bluetooth девайсы. GATT описывает их свойства и взаимодействия. Для нашего проекта, это все, что нам нужно знать. Полезной для нас ссылкой так же является список сервисов (девайсов по факту). Тут я нашел сервис Heart Rate (org.bluetooth.service.heart_rate) который похоже, то, что нам нужно.

Для того, что бы подключить устройство к браузеру, пользователь должен осмысленно, повзаимодействовать с UI. Так себе конечно безопасность, учитывая, что заходя в зал мой пульсометр молча конектится ко всему чему вздумается (в свое время я этому удивился). Спасибо конечно разработчикам браузеров, но why?! Ну да ладно, не сложно и не так уже противно.

Давайте добавим кнопоку и обработчик на страницу в тело :



  

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

Для того, что бы подключить устройство, мы должны использовать navigator.bluetooth.requestDevice. Данный метод умеет принимать массив фильтров. Так как наше приложение будет работать по большей части только с пульсометрами, мы отфильтруем по ним:

navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] })

Откройте html файл в браузере или используйте browser-sync:

browser-sync  start --server --files ./

На мне одет пульсометр и спустя несколько секунд Chrome его нашел:


5vcjn60lsqud0edhusm5awmsrom.png

После того как мы нашли необходимый нам девайс, необходимо считывать с него данные. Для этого подключаем его к GATT серверу:

navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] })
  .then((device) => {
     return device.gatt.connect();
   })

Данные которые мы хотим считывать находятся в характеристиках сервиса (Service Characteristics). У пульсометров всего 3 характеристики, и нас интересует именно org.bluetooth.characteristic.heart_rate_measurement

Для того, что бы считать эту характеристику нам необходимо получить главный сервис. Честно сказать не знаю, WHY. Быть может некоторые девайсы имеют несколько sub сервисов. Затем получить характеристику и подписаться на нотификации.

.then(server => {
            return server.getPrimaryService('heart_rate');
          })
          .then(service => {
            return service.getCharacteristic('heart_rate_measurement');
          })
          .then(characteristic => characteristic.startNotifications())
          .then(characteristic => {
            characteristic.addEventListener(
              'characteristicvaluechanged', handleCharacteristicValueChanged
            );
          })
          .catch(error => { console.log(error); });

          function handleCharacteristicValueChanged(event) {
            var value = event.target.value;
            console.log(parseValue(value));
          }

parseValue функция, которая используется для парсинга данных, спецификацию данных вы можете найти тут — org.bluetooth.characteristic.heart_rate_measurement. Детально на этой функции останавливаться не будем, там все банально.


parseValue
 parseValue = (value) => {
        // В Chrome 50+ используется DataView.
        value = value.buffer ? value : new DataView(value);
        let flags = value.getUint8(0);

        // Определяем формат
        let rate16Bits = flags & 0x1;
        let result = {};
        let index = 1;

        // Читаем в зависимости от типа
        if (rate16Bits) {
          result.heartRate = value.getUint16(index, /*littleEndian=*/true);
          index += 2;
        } else {
          result.heartRate = value.getUint8(index);
          index += 1;
        }

        // RR интервалы
        let rrIntervalPresent = flags & 0x10;
        if (rrIntervalPresent) {
          let rrIntervals = [];
          for (; index + 1 < value.byteLength; index += 2) {
            rrIntervals.push(value.getUint16(index, /*littleEndian=*/true));
          }
          result.rrIntervals = rrIntervals;
        }

        return result;
      }

Взял отсюда: heartRateSensor.js

И так, в консольке мы видим необходимые нам данные. Помимо пульса, мой пульсометр еще показывает RR интервалы. Я так и не придумал как их использовать, это вам домашнее задание:)


Полный код страницы



  
  
  
  Document


  

  



Дизайн

Следующим этапом необходимо продумать дизайн приложения. Ох, конечно простая на первый вид статья превращается в нетривиальную задачу. Хочется использовать всевозможные пафосные вещи и уже в голове очередь из статей которые не обходимо прочитать по CSS Grids, Flexbox и манипуляции CSS анимацией используя JS (Аниманция пульса дело не статичное).


Скетч

Мне нравится красивый дизайн, но дизайнер с меня так себе.
Фотошопа у меня нет, будем как-то выкручиваться по ходу дела.
Для начала давайте создадим новый Vue.js проект используя Vue-cli

vue create heart-rate

Я выбрал ручную настройку и первая страница настроек у меня выглядит так:

nzuuhaq4uzja0r9tee3s1v4uofi.png

Далее выбирайте под себя, но у меня конфиг Airbnb, Jest и Sass.

Посмотрел половину уроков по CSS Grids от Wes Bos, рекомендую, они бесплатные.
Самое время заняться первоначальной версткой. Мы не будем использовать какие-либо CSS фреймворки, все свое. Разумеется и над поддержкой мы не думаем.


Магия рисования совы

И так, первым делом давайте определим наш layout. По факту приложение будет состоять из двух частей. Мы их так и назовем — first и second. В первой части у нас будет числовое представление (ударов в минуту), во второй график.
Цветовую схему я решил украсть отсюда.

jc9fsosdk0djpc-kqdllsex7pei.png

Запускаем наше Vue приложение, если вы еще этого не сделали:

npm run serve

Тулза сама откроет браузер (или нет), там есть хот релоад и линка для внешнего тестирования. Я сразу положил возле себя мобилку, ведь мы думаем о mobile first дизайне. К сожалению, я добавил в шаблон PWA, и на мобилке, кеш чистится при закрытии браузера, но бывает и ок обновляется на сохранение. В общем непонятный момент с которым я не стал разбираться.

Для начала добавим utils.js, с нашей функцией парсинга значений, немного отрефакторив его под eslint в проекте.


utils.js
/* eslint no-bitwise: ["error", { "allow": ["&"] }] */

export const parseHeartRateValues = (data) => {
  // В Chrome 50+ используется DataView.
  const value = data.buffer ? data : new DataView(data);
  const flags = value.getUint8(0);

  // Определяем формат
  const rate16Bits = flags & 0x1;
  const result = {};
  let index = 1;

  // Читаем в зависимости от типа
  if (rate16Bits) {
    result.heartRate = value.getUint16(index, /* littleEndian= */true);
    index += 2;
  } else {
    result.heartRate = value.getUint8(index);
    index += 1;
  }

  // RR интервалы
  const rrIntervalPresent = flags & 0x10;
  if (rrIntervalPresent) {
    const rrIntervals = [];
    for (; index + 1 < value.byteLength; index += 2) {
      rrIntervals.push(value.getUint16(index, /* littleEndian= */true));
    }
    result.rrIntervals = rrIntervals;
  }

  return result;
};

export default {
  parseHeartRateValues,
};

Затем убираем все лишнее из HelloWolrd.vue переименовав его в HeartRate.vue, этот компонент будет отвечать за отображения ударов в минуту.





// Скоупед стили SCSS


Создаем HeartRateChart.vue для графика:

// HeartRateChart.vue



Обновляем App.vue:


App.vue





И собственно говоря mixins.scss, пока тут только один миксин который отвечает за цвет иконки и текста отображающего удары в минуту.

@mixin heart-rate-gradient {
  background: -webkit-linear-gradient(#f34193, #8f48ed);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
}

Получилось, вот такое:


Картиночки

5uujlhywqug3xrqfs7hx9bi2jgi.png

3pkcrp_jlw0pebzdujftsbixj6g.png

Из интересных моментов — используются нативные CSS Variables, но mixins от SCSS.
Вся страница это CSS Grid:

display: grid;
grid-gap: 1rem;
height: 100vh;
grid-template-rows: 1fr 1fr;
grid-template-areas: "first" "second";

Подобно flexbox, родительский контейнер должен иметь какой-то display. В данном случае это grid.
grid-gap — своего рода пробелы между columns и rows.
height: 100vh — высота на весь viewport, это необходимо, что бы fr занимал пространство во всю высоту (2 части нашего приложения).
grid-template-rows — определяем наш темплейт, fr это сахарная единица, которая учитывает grid-gap и прочее влияющие на размер свойства.
grid-template-areas — в нашем примере просто семантическая.

Хром на данный момент до сих пор не завез нормальных тулзов для инспекции CSS Grids:

l4zpkxvws1hyhw3s5vj8pjkxcts.png

В то же время в мазиле:

s6sv9erwvogh3ycfwxdrmn0xkzy.png

Теперь нам необходимо добавить обработчик клика на кнопку, аналогично как мы это делали раньше.
Добавляем обработчик:

// App.vue

// Methods: {}
 onClick() {
      navigator.bluetooth.requestDevice({
        filters: [{ services: ['heart_rate'] }],
      })
        .then(device => device.gatt.connect())
        .then(server => server.getPrimaryService('heart_rate'))
        .then(service => service.getCharacteristic('heart_rate_measurement'))
        .then(characteristic => characteristic.startNotifications())
        .then(characteristic => characteristic.addEventListener('characteristicvaluechanged', this.handleCharacteristicValueChanged.bind(this)))
        .catch(error => console.log(error));
    },

Не забывайте, что это работает только в хроме и только в хроме на андроиде:)
[картинка]

Далее добавим график, мы будем использовать Chart.js и обертку под Vue.js

npm install vue-chartjs chart.js --save

Polar выделяет 5ть зон тренировки. По этому нам надо как-то различать эти зоны и/или хранить их. У нас уже есть heartRateData. Для эстетики, сделаем дефолтное значение вида:

heartRateData: [[], [], [], [], [], []],

Будем раскидывать значения согласно 5ти зонам:

pushData(index, value) {
     this.heartRateData[index].push({ x: Date.now(), y: value });
     this.heartRateData = [...this.heartRateData];
},
handleCharacteristicValueChanged(e) {
      const value = parseHeartRateValues(e.target.value).heartRate;
      this.heartRate = value;

      switch (value) {
        case value > 104 && value < 114:
          this.pushData(1, value);
          break;
        case value > 114 && value < 133:
          this.pushData(2, value);
          break;
        case value > 133 && value < 152:
          this.pushData(3, value);
          break;
        case value > 152 && value < 172:
          this.pushData(4, value);
          break;
        case value > 172:
          this.pushData(5, value);
          break;

        default: this.pushData(0, value);
      }
    },

Vue.js ChartJS используются следующим образом:

// Example.js
import { Bar } from 'vue-chartjs'

export default {
  extends: Bar,
  mounted () {
    this.renderChart({
      labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
      datasets: [
        {
          label: 'GitHub Commits',
          backgroundColor: '#f87979',
          data: [40, 20, 12, 39, 10, 40, 39, 80, 40, 20, 12, 11]
        }
      ]
    })
  }
}

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

В нашем случае необходимо обновлять график по мере поступления новых данных, по этому мы спрячем отображение в отдельном методе updateChart и будем вызывать его на mounted и используя вотчеры следить за проперти values:


HeartRateChart.vue

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

// App.vue
... import data from './__mock__/data'; ... onClickTest() { this.heartRateData = [ data(300, 60, 100), data(300, 104, 114), data(300, 133, 152), data(300, 152, 172), data(300, 172, 190), ]; this.heartRate = 73; },
// __mock__/date.js
const getRandomIntInclusive = (min, max) =>
  Math.floor(Math.random() * ((Math.floor(max) - Math.ceil(min)) + 1)) + Math.ceil(min);

export default (count, from, to) => {
  const array = [];
  for (let i = 0; i < count; i += 1) {
    array.push({ y: getRandomIntInclusive(from, to), x: i });
  }

  return array;
};

Результат:

hfu4xsmncuusjlifimhga-nxwt0.png


Выводы

Использовать Web Bluetooth API очень просто. Есть моменты с необходимостью считывания данных используя побитовые операторы, но это видать специфика области. Из минусов конечно же является поддержка. На данный момент это только хром, а на мобилках хром и только на андроиде.

188db8bf29bf9d4e6a37b42d79f787d5.png

Github исходники
Демо

© Habrahabr.ru