Продолжаем обсуждать темы затронутые на 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 = (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 в проекте.
/* 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:
<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:
<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 очень просто. Есть моменты с необходимостью считывания данных используя побитовые операторы, но это видать специфика области. Из минусов конечно же является поддержка. На данный момент это только хром, а на мобилках хром и только на андроиде.

