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

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

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

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

Собственно его я и решил "взламывать". У меня таких игрушек нет и конструкцией использование не предусмотрено, но есть пульсометр 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). Поскольку мой пульсометр девайс сертифицированный хоть и ковался в китайских мастерских, но с соблюдением стандартов, его подключение и использование не должно вызвать каких либо сложностей. GATT описывает их свойства и взаимодействия. Я сильно не вдавался в подробности, но если просто, то это своего рода спецификация которой следуют Bluetooth девайсы. Полезной для нас ссылкой так же является список сервисов (девайсов по факту). Для нашего проекта, это все, что нам нужно знать. Тут я нашел сервис Heart Rate (org.bluetooth.service.heart_rate) который похоже, то, что нам нужно.

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

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

<button id="pair">Pair device</button> <script> window.onload = () => ); } </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(); })

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

Честно сказать не знаю, 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 фреймворки, все свое. Посмотрел половину уроков по CSS Grids от Wes Bos, рекомендую, они бесплатные.
Самое время заняться первоначальной версткой. Разумеется и над поддержкой мы не думаем.

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

По факту приложение будет состоять из двух частей. И так, первым делом давайте определим наш 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";

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

Хром на данный момент до сих пор не завез нормальных тулзов для инспекции 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 исходники
Демо


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

[Перевод] Выбираемся из кроличей норы SPA при помощи современного Rails

TL;DR: Тропа SPA темна и полна ужасов. Ты можешь бесстрашно сражаться с ними… или выбрать другой путь, который приведёт тебя к нужному месту: современный Rails. Тогда я был убеждён в том, что мгновенное время ответа во время взаимодействия с пользователем ...

Разработка редактора для создания веб сайтов/лендингов (Опыт)

(Art by http://www.simonstalenhag.se/) Предыстория / Дисклеймер Но не думайте, что вы аудитория на которой я отрабатываю текст, просто мне так проще подготовиться.На данный момент я Front-end разработчик в Conductor/WeWork. Всем привет, данная статья является по факту материалом для моего выступления ...