Используем 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
с типичной разметкой:
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 его нашел:
После того как мы нашли необходимый нам девайс, необходимо считывать с него данные. Для этого подключаем его к 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 интервалы. Я так и не придумал как их использовать, это вам домашнее задание:)
Document
Дизайн
Следующим этапом необходимо продумать дизайн приложения. Ох, конечно простая на первый вид статья превращается в нетривиальную задачу. Хочется использовать всевозможные пафосные вещи и уже в голове очередь из статей которые не обходимо прочитать по 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
, этот компонент будет отвечать за отображения ударов в минуту.
{{value}}
// Скоупед стили SCSS
Создаем HeartRateChart.vue
для графика:
// HeartRateChart.vue
chart
Обновляем App.vue
:
И собственно говоря 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
// 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
:
Наше приложение готово. Но, что бы не скакать перед экраном и доводить себя до 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;
};
Результат:
Выводы
Использовать Web Bluetooth API очень просто. Есть моменты с необходимостью считывания данных используя побитовые операторы, но это видать специфика области. Из минусов конечно же является поддержка. На данный момент это только хром, а на мобилках хром и только на андроиде.
Github исходники
Демо