Nous continuons à discuter des sujets abordés lors de la conférence You Gonna Love Frontend . Cet article est inspiré d'une conférence de Michaela Lehr . Les vidéos de la conférence seront disponibles cette semaine tant qu'il y aura des diapositives . ( Vidéo déjà disponible )

Michaela Lehr a connecté le vibreur au navigateur à l'aide d'API Web, à savoir l'API Web Bluetooth. Trafic prosnifère entre l'application et le vibreur, elle a constaté que les commandes envoyées sont très simples, par exemple: vibrate: 5
. Puis, après lui avoir appris à vibrer au son des gémissements d'une vidéo qu'elle a pu trouver sur Internet, elle a atteint ses objectifs :)
Je n'ai pas de tels jouets et la conception n'est pas destinée à être utilisée, mais il existe un moniteur de fréquence cardiaque Polar H10 qui utilise Bluetooth pour transmettre des données. En fait, j'ai décidé de "le casser".
Pas de piratage
Tout d'abord, il vaut la peine de comprendre comment connecter l'appareil au navigateur? Google ou Yandex, selon vos envies: Web Bluetooth API
, et sur le premier lien nous voyons un article sur ce sujet.
Malheureusement, tout est beaucoup plus simple et il n'y a rien à renifler si vous ne voulez pas envoyer quelque chose à un appareil qui ne le veut pas. Dans le même article, il y a même une démonstration d'un moniteur de fréquence cardiaque connecté.

Cela m'a fortement découragé, même le code source l'est. Quelle heure est passée?
Nous connectons l'appareil
Créons index.html
avec un balisage typique:
<!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>
Étant donné que mon moniteur de fréquence cardiaque certifié a été forgé dans des ateliers chinois, mais conformément aux normes, sa connexion et son utilisation ne devraient pas poser de problème. Il y a une telle chose - les attributs génériques (GATT) . Je ne suis pas entré dans les détails, mais si c'est simple, c'est une sorte de spécification que les appareils Bluetooth suivent. Le GATT décrit leurs propriétés et leurs interactions. Pour notre projet, c'est tout ce que nous devons savoir. Un lien utile pour nous est également une liste de services (appareils en fait). J'ai ensuite trouvé le service Heart Rate (org.bluetooth.service.heart_rate) qui ressemble à ce dont nous avons besoin.
Afin de connecter l'appareil au navigateur, l'utilisateur doit interagir consciemment avec l'interface utilisateur. Bon sang, bien sûr, la sécurité, étant donné qu'en entrant dans la salle de gym, mon moniteur de fréquence cardiaque se connecte silencieusement à tout ce que je veux (à un moment, j'ai été surpris par cela). Merci bien sûr aux développeurs de navigateurs, mais pourquoi?! Eh bien, ce n'est pas difficile et pas si dégoûtant.
Ajoutons un bouton et un gestionnaire à la page dans le corps de <body>
:
<button id="pair">Pair device</button> <script> window.onload = () => { const button = document.getElementById('pair') button.addEventListener('pointerup', function(event) { </script>
Comme vous pouvez le voir, il n'y a pour l'instant aucune vue que j'ai promis à en juger par le titre. Mais je ne sais pas tout moi-même et j'écris un article en cours de route. Alors, ce que nous faisons de cette façon :)
Afin de connecter l'appareil, nous devons utiliser navigator.bluetooth.requestDevice
. Cette méthode peut accepter un tableau de filtres. Étant donné que notre application ne fonctionnera pour la plupart qu'avec des moniteurs de fréquence cardiaque, nous filtrerons par eux:
navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] })
Ouvrez le fichier html dans un navigateur ou utilisez la browser-sync
:
browser-sync start --server --files ./
Je porte un moniteur de fréquence cardiaque et après quelques secondes, Chrome l'a trouvé:

Après avoir trouvé l'appareil dont nous avons besoin, il est nécessaire d'en lire les données. Pour ce faire, connectez-le au serveur GATT:
navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] }) .then((device) => { return device.gatt.connect(); })
Les données que nous voulons lire se trouvent dans les caractéristiques du service. Les moniteurs de fréquence cardiaque n'ont que 3 caractéristiques et nous sommes intéressés par org.bluetooth.characteristic.heart_rate_measurement
Pour tenir compte de cette caractéristique, nous devons obtenir le service principal. Honnêtement, je ne sais pas POURQUOI. Peut-être que certains appareils ont plusieurs sous-services. Obtenez ensuite un témoignage et inscrivez-vous aux notifications.
.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
fonction qui est utilisée pour analyser les données, la spécification des données peut être trouvée ici - org.bluetooth.characteristic.heart_rate_measurement . Nous ne nous attarderons pas sur cette fonction en détail, tout y est banal.
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; }
Pris d'ici: heartRateSensor.js
Et donc, dans la console, nous voyons les données dont nous avons besoin. En plus de la fréquence cardiaque, mon moniteur de fréquence cardiaque affiche également des intervalles RR. Je n'ai jamais trouvé comment les utiliser, c'est tes devoirs :)
Code pleine page <!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>
La conception
L'étape suivante consiste à considérer la conception de l'application. Oh, bien sûr, un article simple à première vue se transforme en tâche non triviale. Je voudrais utiliser toutes sortes de pathos et dans ma tête il y a une file d'attente d'articles que je dois lire sur les grilles CSS, Flexbox et Manipulations d'animation CSS utilisant JS (l'animation par impulsions n'est pas statique).
Esquisse
J'aime le beau design, mais le créateur est comme ça avec moi.
Je n'ai pas Photoshop, nous allons en quelque sorte nous écarter.
Tout d'abord, créons un nouveau projet Vue.js en utilisant Vue-cli
vue create heart-rate
J'ai choisi les paramètres manuels et la première page de paramètres ressemble à ceci:

Ensuite, choisissez par vous-même, mais j'ai une configuration Airbnb, Jest et Sass.
J'ai regardé la moitié des tutoriels de Wes Bos CSS Grids, je recommande qu'ils soient gratuits.
Il est temps de faire la mise en page initiale. Nous n'utiliserons aucun framework CSS, le nôtre. Bien sûr, nous ne pensons pas non plus au soutien.
Chouette dessin magique
Et donc, tout d'abord, définissons notre layout
. En fait, la demande comprendra deux parties. Nous les appellerons ainsi - first
et second
. Dans la première partie, nous aurons une représentation numérique (battements par minute), dans le deuxième graphique.
J'ai décidé de voler le jeu de couleurs d'ici .

Nous lançons notre application Vue si vous ne l'avez pas déjà fait:
npm run serve
L'outil lui-même ouvrira le navigateur (ou non), il y a un rechargement à chaud et un lien pour les tests externes. J'ai tout de suite mis un téléphone portable à côté de moi, car nous pensons au mobile first design. Malheureusement, j'ai ajouté un PWA au modèle, et sur le téléphone mobile, le cache est nettoyé lorsque le navigateur est fermé, mais cela se produit et ok est mis à jour pour enregistrer. En général, un moment incompréhensible avec lequel je ne comprenais pas.
Tout d'abord, ajoutez utils.js
, avec notre fonction d'analyse des valeurs, en le refactorisant un peu sous eslint dans le projet.
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, };
Ensuite, nous HelloWolrd.vue
tous les éléments inutiles de HelloWolrd.vue
renommant HeartRate.vue
, ce composant sera responsable de l'affichage des battements par minute.
<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>
Créez HeartRateChart.vue
pour le graphique:
// HeartRateChart.vue <template> <div> chart </div> </template> <script> export default { name: 'HeartRateChart', props: { values: { type: Array, default: () => [], . . }, }, }; </script>
Mise à jour d' 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
Et en fait mixins.scss
, alors qu'il n'y a qu'un seul mixin qui est responsable de la couleur de l'icône et du texte affichant les battements par minute.
@mixin heart-rate-gradient { background: -webkit-linear-gradient(
Il s'est avéré comme ceci:
Parmi les points intéressants - les variables CSS natives sont utilisées, mais les mixins
de SCSS.
La page entière est CSS Grid
:
display: grid; grid-gap: 1rem; height: 100vh; grid-template-rows: 1fr 1fr; grid-template-areas: "first" "second";
Comme flexbox
, le conteneur parent doit avoir une sorte d' display
. Dans ce cas, c'est la grid
.
grid-gap
- une sorte d'espaces entre les columns
et les rows
.
height: 100vh
- la hauteur de la viewport
entière, il est nécessaire pour fr
occuper l'espace en pleine hauteur (2 parties de notre application).
grid-template-rows
- définissez notre modèle, fr
est une unité de sucre qui prend en compte l' grid-gap
et d'autres propriétés qui affectent la taille.
grid-template-areas
- dans notre exemple, juste sémantique.
Chrome n'a pas encore fourni d'outils normaux pour l'inspection des grilles CSS:

En même temps dans le manchon:

Maintenant, nous devons ajouter un gestionnaire de clic de bouton, similaire à la façon dont nous l'avons fait auparavant.
Ajoutez un gestionnaire:
// 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)); },
N'oubliez pas que cela ne fonctionne qu'en chrome et uniquement en chrome sur android :)
Ensuite, ajoutez un graphique, nous utiliserons Chart.js
et un wrapper pour Vue.js
npm install vue-chartjs chart.js
Polar dispose de 5 zones d'entraînement . Par conséquent, nous devons en quelque sorte distinguer ces zones et / ou les stocker. Nous avons déjà heartRateData
. Pour l'esthétique, nous allons faire la valeur par défaut du formulaire:
heartRateData: [[], [], [], [], [], []],
Nous diffuserons les valeurs selon 5 zones:
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 sont utilisés comme suit:
// 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] } ] }) } }
Vous importez le style de graphique nécessaire, développez votre composant et utilisez this.renderChart
afficher le graphique.
Dans notre cas, il est nécessaire de mettre à jour la planification à mesure que de nouvelles données arrivent, nous allons donc masquer l'affichage dans une méthode updateChart
distincte et l'appeler sur mounted
et utiliser la montre pour surveiller les 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>
Notre candidature est prête. Mais, pour ne pas sauter devant l'écran et nous amener au 5ème niveau, ajoutons un bouton qui va générer des données aléatoires pour les 5 niveaux pour nous:
// 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; };
Résultat:

Conclusions
L'utilisation de l'API Web Bluetooth est très simple. Il y a des moments où il faut lire des données à l'aide d'opérateurs au niveau du bit, mais c'est un domaine spécifique. Bien sûr, le soutien est l'un des inconvénients. Pour le moment, il ne s'agit que de chrome, et sur les téléphones mobiles de chrome et uniquement sur Android.

Sources Github
Démo