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


Продолжаем обсуждать темы затронутые на 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 исходники
Демо

Let’s block ads! (Why?)