Как стать автором
Обновить

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

Время на прочтение12 мин
Количество просмотров25K

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



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


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


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


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


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



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


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


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


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>

</body>
</html>

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


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


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


<button id="pair">Pair device</button>

  <script>
    window.onload = () => {
      const button = document.getElementById('pair')
      button.addEventListener('pointerup', function(event) {
        // TODO:
      });
    }
  </script>

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


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


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

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


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

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




После того как мы нашли необходимый нам девайс, необходимо считывать с него данные. Для этого подключаем его к 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 интервалы. Я так и не придумал как их использовать, это вам домашнее задание :)


Полный код страницы
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  <button id="pair">Pair device</button>

  <script>
    window.onload = () => {
      const button = document.getElementById('pair')

      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;
      }

      button.addEventListener('pointerup', function(event) {
        navigator.bluetooth.requestDevice({
            filters: [{ services: ['heart_rate'] }]
          })
          .then((device) => {
            return device.gatt.connect();
          })
          .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));
            // See https://github.com/WebBluetoothCG/demos/blob/gh-pages/heart-rate-sensor/heartRateSensor.js
          }
      });
    }
  </script>
</body>
</html>

Дизайн


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


Скетч


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


vue create heart-rate

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



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


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


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


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



Запускаем наше 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, этот компонент будет отвечать за отображения ударов в минуту.


<template>
  <div>
    <span>{{value}}</span>
  </div>
</template>

<script>
export default {
  name: 'HeartRate',
  props: { // Пропсы которые получает элемент с проверкой типа и дефолтным значением
    value: {
      type: Number,
      default: null,
    },
  },
};
</script>

// Скоупед стили SCSS
<style scoped lang="scss">
  @import '../styles/mixins';

  div {
    @include heart-rate-gradient;
    font-size: var(--heart-font-size); // Миксин который мы определим ниже
  }
</style>

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


// HeartRateChart.vue
<template>
  <div>
    chart
  </div>
</template>

<script>
export default {
  name: 'HeartRateChart',
  props: {
    values: {
      type: Array,
      default: () => [], для объектов надо делать функцию с дефолтным значением. Что бы не шарить один и тот же объект.
    },
  },
};
</script>

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


App.vue
<template>
  <div class=app>
    <div class=heart-rate-wrapper>
      <HeartRate v-if=heartRate :value=heartRate />
      <i v-else class="fas fa-heartbeat"></i>
      <div>
        <button v-if=!heartRate class=blue>Click to start</button>
      </div>
    </div>
    <div class=heart-rate-chart-wrapper>
      <HeartRateChart :values=heartRateData />
    </div>
  </div>
</template>

<script>
import HeartRate from './components/HeartRate.vue';
import HeartRateChart from './components/HeartRateChart.vue';
import { parseHeartRateValues } from './utils';

export default {
  name: 'app',
  components: {
    HeartRate,
    HeartRateChart,
  },
  data: () => ({
    heartRate: 0,
    heartRateData: [],
  }),
  methods: {
    handleCharacteristicValueChanged(e) {
      this.heartRate = parseHeartRateValues(e.target.value).heartRate;
    },
  },
};
</script>

<style lang="scss">
  @import './styles/mixins';

  html, body {
    margin: 0px;
  }

  :root {
    // COLORS
    --first-part-background-color: #252e47;
    --second-part-background-color: #212942;
    --background-color: var(--first-part-background-color);
    --text-color: #fcfcfc;

    // TYPOGRAPHY
    --heart-font-size: 2.5em;
  }

  .app {
    display: grid;
    grid-gap: 1rem;
    height: 100vh;
    grid-template-rows: 1fr 1fr;
    grid-template-areas: "first" "second";
    font-family: 'Avenir', Helvetica, Arial, sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-align: center;
    background-color: var(--background-color);
    color: var(--text-color);
  }

  .heart-rate-wrapper {
    padding-top: 5rem;
    background-color: var(--first-part-background-color);
    font-size: var(--heart-font-size);

    .fa-heartbeat {
      @include heart-rate-gradient;
      font-size: var(--heart-font-size);
    }

    button {
      transition: opacity ease;
      border: none;
      border-radius: .3em;
      padding: .6em 1.2em;
      color: var(--text-color);
      font-size: .3em;
      font-weight: bold;
      text-transform: uppercase;
      cursor: pointer;
      opacity: .9;

      &:hover {
        opacity: 1;
      }

      &.blue {
        background: linear-gradient(to right, #2d49f7, #4285f6);
      }
    }
  }
</style>

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


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

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


Картиночки



Из интересных моментов — используются нативные 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:



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



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


// App.vue
<button v-if=!heartRate @click=onClick class=blue>Click to start</button>

// 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
<script>
import { Scatter } from 'vue-chartjs';

export default {
  extends: Scatter,
  name: 'HeartRateChart',
  props: {
    values: {
      type: Array,
      default: () => [[], [], [], [], [], []],
    },
  },
  watch: {
    values() {
      this.updateChart();
    },
  },
  mounted() {
    this.updateChart();
  },

  methods: {
    updateChart() {
      this.renderChart({
        datasets: [
          {
            label: 'Chilling',
            data: this.values[0],
            backgroundColor: '#4f775c',
            borderColor: '#4f775c',
            showLine: true,
            fill: false,
          },
          {
            label: 'Very light',
            data: this.values[1],
            backgroundColor: '#465f9b',
            borderColor: '#465f9b',
            showLine: true,
            fill: false,
          },
          {
            label: 'Light',
            data: this.values[2],
            backgroundColor: '#4e4491',
            borderColor: '#4e4491',
            showLine: true,
            fill: false,
          },
          {
            label: 'Moderate',
            data: this.values[3],
            backgroundColor: '#6f2499',
            borderColor: '#6f2499',
            showLine: true,
            fill: false,
          },
          {
            label: 'Hard',
            data: this.values[4],
            backgroundColor: '#823e62',
            borderColor: '#823e62',
            showLine: true,
            fill: false,
          },
          {
            label: 'Maximum',
            data: this.values[5],
            backgroundColor: '#8a426f',
            borderColor: '#8a426f',
            showLine: true,
            fill: false,
          },
        ],
      }, {
        animation: false,
        responsive: true,
        maintainAspectRatio: false,
        elements: {
          point: {
            radius: 0,
          },
        },
        scales:
          {
            xAxes: [{
              display: false,
            }],
            yAxes: [{
              ticks: {
                beginAtZero: true,
                fontColor: '#394365',
              },
              gridLines: {
                color: '#2a334e',
              },
            }],
          },
      });
    },
  },
};
</script>

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


// App.vue
<div>
   <button v-if=!heartRate @click=onClickTest class=blue>Test dataset</button>
</div>
...
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;
};

Результат:



Выводы


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



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

Теги:
Хабы:
+30
Комментарии2

Публикации