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