Continuamos discutiendo los temas discutidos en la Conferencia Frontend You Gonna Love . Este artículo está inspirado en una conferencia de Michaela Lehr . Los videos de la conferencia estarán disponibles esta semana siempre que haya diapositivas . ( Video ya disponible )

Michaela Lehr conectó el vibrador al navegador mediante API web, es decir, la API Bluetooth web. Prosniferif tráfico entre la aplicación y el vibrador, encontró que los comandos enviados son muy simples, por ejemplo: vibrate: 5
. Luego, después de haberle enseñado a vibrar con el sonido de gemidos de un video que podía encontrar en Internet, logró sus objetivos :)
No tengo tales juguetes y el diseño no está diseñado para su uso, pero hay un monitor de frecuencia cardíaca Polar H10 que usa Bluetooth para transmitir datos. En realidad, decidí "descifrarlo".
No hackear
En primer lugar, ¿vale la pena entender cómo conectar el dispositivo al navegador? Google o Yandex, dependiendo de sus inclinaciones: Web Bluetooth API
, y en el primer enlace vemos un artículo sobre este tema.
Desafortunadamente, todo es mucho más simple y no hay nada que olfatear si no desea enviar algo a un dispositivo que no lo desea. En el mismo artículo, incluso hay una demostración de un monitor de frecuencia cardíaca conectado.

Me desalentó muchísimo, incluso el código fuente lo es. ¿A qué hora se fue?
Conectamos el dispositivo
index.html
con el marcado típico:
<!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>
Dado que mi dispositivo de monitor de frecuencia cardíaca certificado fue falsificado en talleres chinos, pero cumpliendo con los estándares, su conexión y uso no deberían causar ninguna dificultad. Existe tal cosa: atributos genéricos (GATT) . No entre en muchos detalles, pero si es simple, es un tipo de especificación que siguen los dispositivos Bluetooth. El GATT describe sus propiedades e interacciones. Para nuestro proyecto, esto es todo lo que necesitamos saber. Un enlace útil para nosotros es también una lista de servicios (dispositivos de hecho). Luego encontré el servicio de frecuencia cardíaca (org.bluetooth.service.heart_rate) que se parece a lo que necesitamos.
Para conectar el dispositivo al navegador, el usuario debe interactuar conscientemente con la interfaz de usuario. Así que, por supuesto, seguridad, teniendo en cuenta que al ingresar al gimnasio, mi monitor de frecuencia cardíaca se conecta en silencio a todo lo que quiero (en un momento me sorprendió esto). Gracias por supuesto a los desarrolladores del navegador, pero ¿por qué? Oh, bueno, no es difícil y no es tan desagradable.
Agreguemos un botón y un controlador a la página en el cuerpo de <body>
:
<button id="pair">Pair device</button> <script> window.onload = () => { const button = document.getElementById('pair') button.addEventListener('pointerup', function(event) { </script>
Como puede ver, hasta ahora no hay Vue que prometí a juzgar por el título. Pero yo no lo sé todo y estoy escribiendo un artículo en el camino. Entonces, ¿qué estamos haciendo de esta manera :)
Para conectar el dispositivo, debemos usar navigator.bluetooth.requestDevice
. Este método puede aceptar una variedad de filtros. Dado que nuestra aplicación funcionará en su mayor parte solo con monitores de frecuencia cardíaca, filtraremos por ellos:
navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] })
Abra el archivo html en un navegador o use browser-sync
:
browser-sync start --server --files ./
Estoy usando un monitor de frecuencia cardíaca y después de unos segundos Chrome lo encontró:

Después de encontrar el dispositivo que necesitamos, es necesario leer los datos del mismo. Para hacer esto, conéctelo al servidor GATT:
navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] }) .then((device) => { return device.gatt.connect(); })
Los datos que queremos leer están en las Características del servicio. Los monitores de frecuencia cardíaca tienen solo 3 características, y estamos interesados en org.bluetooth.characteristic.heart_rate_measurement
Para considerar esta característica, necesitamos obtener el servicio principal. Sinceramente, no sé por qué. Quizás algunos dispositivos tienen varios servicios secundarios. Luego obtenga una caracterización y regístrese para recibir notificaciones.
.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
función que se utiliza para analizar datos, la especificación de datos se puede encontrar aquí: org.bluetooth.characteristic.heart_rate_measurement . No nos detendremos en esta función en detalle, todo es trivial allí.
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, 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, true)); } result.rrIntervals = rrIntervals; } return result; }
Tomó desde aquí: heartRateSensor.js
Y así, en la consola vemos los datos que necesitamos. Además de la frecuencia cardíaca, mi monitor de frecuencia cardíaca también muestra intervalos RR. Nunca se me ocurrió cómo usarlos, esta es tu tarea :)
Código de página completa <!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, 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, 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>
Diseño
El siguiente paso es considerar el diseño de la aplicación. Oh, por supuesto, un artículo que es simple a primera vista se convierte en una tarea no trivial. Me gustaría usar todo tipo de pathos y en mi cabeza hay una lista de artículos que necesito leer en CSS Grids, Flexbox y Manipulaciones de animación CSS usando JS (la animación de pulso no es estática)
Bosquejo
Me gusta el diseño hermoso, pero el diseñador es regular conmigo.
No tengo Photoshop, de alguna manera nos saldremos del camino.
Primero, creemos un nuevo proyecto Vue.js usando Vue-cli
vue create heart-rate
Elegí la configuración manual y la primera página de configuración se ve así:

A continuación, elija usted mismo, pero tengo una configuración de Airbnb, Jest y Sass.
Miré la mitad de los tutoriales de CSS Bos Grids de Wes Bos , recomiendo que sean gratuitos.
Es hora de hacer el diseño inicial. No utilizaremos ningún framework CSS, todos los nuestros. Por supuesto, tampoco pensamos en el soporte.
Búho dibujando magia
Y así, en primer lugar, definamos nuestro layout
. De hecho, la aplicación constará de dos partes. Los llamaremos así, first
y second
. En la primera parte tendremos una representación numérica (latidos por minuto), en la segunda gráfica.
Decidí robar el esquema de color de aquí .

Lanzamos nuestra aplicación Vue si aún no lo ha hecho:
npm run serve
La herramienta en sí abrirá el navegador (o no), hay una recarga en caliente y un enlace para pruebas externas. Inmediatamente puse un teléfono móvil a mi lado, porque estamos pensando en el primer diseño móvil. Desafortunadamente, agregué un PWA a la plantilla, y en el teléfono móvil, el caché se limpia cuando se cierra el navegador, pero sucede, y ok se actualiza para guardar. En general, un momento incomprensible con el que no entendí.
Primero, agregue utils.js
, con nuestra función de análisis de valores, refactorizándolo un poco bajo eslint en el proyecto.
utils.js 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, 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, true)); } result.rrIntervals = rrIntervals; } return result; }; export default { parseHeartRateValues, };
Luego eliminamos todo lo innecesario de HelloWolrd.vue
renombrándolo a HeartRate.vue
, este componente será responsable de mostrar los latidos por minuto.
<template> <div> <span>{{value}}</span> </div> </template> <script> export default { name: 'HeartRate', props: { </script> // SCSS <style scoped lang="scss"> @import '../styles/mixins'; div { @include heart-rate-gradient; font-size: var(--heart-font-size); // } </style>
Cree HeartRateChart.vue
para el gráfico:
// HeartRateChart.vue <template> <div> chart </div> </template> <script> export default { name: 'HeartRateChart', props: { values: { type: Array, default: () => [], . . }, }, }; </script>
Actualización de 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
Y en realidad mixins.scss
, mientras que solo hay un mixin que es responsable del color del icono y del texto que muestra latidos por minuto.
@mixin heart-rate-gradient { background: -webkit-linear-gradient(
Resultó así:
De los puntos interesantes: se usan variables CSS nativas, pero mixins
de SCSS.
Toda la página es CSS Grid
:
display: grid; grid-gap: 1rem; height: 100vh; grid-template-rows: 1fr 1fr; grid-template-areas: "first" "second";
Al igual que flexbox
, el contenedor principal debe tener algún tipo de display
. En este caso, es grid
.
grid-gap
: un tipo de espacios entre columns
y rows
.
height: 100vh
: la altura de toda la viewport
height: 100vh
, es necesario que fr
ocupe el espacio en toda su altura (2 partes de nuestra aplicación).
grid-template-rows
- define nuestra plantilla, fr
es una unidad de azúcar que tiene en cuenta grid-gap
y otras propiedades que afectan el tamaño.
grid-template-areas
- en nuestro ejemplo, simplemente semántico.
Chrome aún no ha entregado herramientas normales para la inspección de CSS Grids:

Al mismo tiempo en el manguito:

Ahora necesitamos agregar un controlador de clic de botón, similar a como lo hicimos antes.
Agregar un controlador:
// 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)); },
No olvides que esto solo funciona en Chrome y solo en Chrome en Android :)
A continuación, agregue un gráfico, usaremos Chart.js
y un contenedor para Vue.js
npm install vue-chartjs chart.js
Polar tiene 5 zonas de entrenamiento . Por lo tanto, necesitamos distinguir de alguna manera entre estas zonas y / o almacenarlas. Ya tenemos heartRateData
. Para la estética, hacemos el valor predeterminado de la forma:
heartRateData: [[], [], [], [], [], []],
Dispersaremos valores de acuerdo a 5 zonas:
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 se utilizan de la siguiente manera:
// 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
el estilo de gráfico necesario, expande su componente y utiliza this.renderChart
mostrar el gráfico.
En nuestro caso, es necesario actualizar la programación a medida que llegan nuevos datos, por lo que updateChart
la pantalla en un método updateChart
separado y lo llamaremos mounted
y usaremos el reloj para monitorear los 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>
Nuestra aplicación está lista. Pero, para no saltar delante de la pantalla y pasar al quinto nivel, agreguemos un botón que generará datos aleatorios para los 5 niveles:
// 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; };
Resultado:

Conclusiones
Usar la API web de Bluetooth es muy simple. Hay momentos con la necesidad de leer datos utilizando operadores bit a bit, pero esta es un área específica. De los inconvenientes, por supuesto, es el apoyo. Por el momento es solo Chrome, y en teléfonos móviles Chrome y solo en Android.

Fuentes de Github
Demo