我们使用Web蓝牙API连接心率监测器并使用Vue.js开发应用程序

我们将继续讨论“ 您会爱上前端”会议上讨论的主题。 本文的灵感来自Michaela Lehr的一次演讲。 只要有幻灯片,会议视频将在本周提供。 ( 视频已可用



Michaela Lehr使用Web API(即Web Bluetooth API)将振动器连接到浏览器。 Prosniferif在应用程序和振动器之间进行通信,她发现发送的命令非常简单,例如: vibrate: 5 。 然后,她教她从网上可以找到的视频中听到to吟声,她实现了自己的目标:)


我没有这样的玩具,设计也不适合使用,但是有一个Polar H10心率监测器,它使用蓝牙来传输数据。 实际上,我决定“破解”。


没有黑客


首先,值得了解如何将设备连接到浏览器? Google或Yandex,具体取决于您的喜好: 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> 

由于我认证的心率监测器设备是在中国车间制造的,但符合标准,因此其连接和使用不会造成任何困难。 有这样的东西- 通用属性(GATT) 。 我并没有详细介绍,但是如果简单的话,那就是蓝牙设备遵循的一种规范。 关贸总协定描述了它们的性质和相互作用。 对于我们的项目,这就是我们需要知道的。 对我们来说,有用的链接也是服务列表 (实际上是设备)。 然后,我发现了心跳服务(org.bluetooth.service.heart_rate) ,它看起来像我们需要的。


为了将设备连接到浏览器,用户必须自觉与UI进行交互。 一般而言,安全性一般,考虑到进入体育馆时,我的心率监测器无声地连接到我想要的一切(一次让我感到惊讶)。 当然要感谢浏览器开发人员,但是为什么呢? 哦,这并不困难,也不会那么令人恶心。


让我们在<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(); }) 

我们要读取的数据在服务特征中。 心率监测器只有3个特征,我们对org.bluetooth.characteristic.heart_rate_measurement感兴趣


为了考虑这一特性,我们需要获得主要服务。 老实说,我不知道为什么。 也许某些设备具有几个子服务。 然后获得推荐并注册通知。


 .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用于解析数据parseValue函数,可以在此处找到数据规范-org.bluetooth.characteristic.heart_rate_measurement 。 我们不会在此功能上详细介绍,那里的一切都是陈旧的。


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, /*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上阅读 弹性盒使用JS的CSS动画处理(脉冲动画不是静态的)。


草绘


我喜欢漂亮的设计,但是设计师和我一样。
我没有Photoshop,我们会以某种方式摆脱困境。
首先,让我们使用Vue-cli创建一个新的Vue.js项目


 vue create heart-rate 

我选择了手动设置,第一个设置页面如下所示:



接下来,自己选择,但我有一个Airbnb,Jest和Sass配置。


我看了一半的Wes Bos CSS Grids教程,我建议它们是免费的。
是时候进行初始布局了。 我们不会全部使用任何CSS框架。 当然,我们也不考虑支持。


猫头鹰绘图魔术


因此,首先让我们定义我们的layout 。 实际上,该应用程序将包括两个部分。 我们将其称为firstsecond 。 在第一部分中,我们将在第二张图中以数字表示(每分钟的心跳数)。
我决定从这里偷配色方案。



如果您尚未启动Vue应用程序,请启动它:


 npm run serve 

该工具本身会打开浏览器(或不打开),有一个热重载和一个用于外部测试的链接。 我立即在我旁边放了一部手机,因为我们正在考虑移动优先设计。 不幸的是,我在模板中添加了PWA,并且在手机上,当关闭浏览器时,缓存被清除了,但是它确实发生了,并且可以更新以保存。 总的来说,这是我无法理解的一个不可理解的时刻。


首先,添加带有我们解析值功能的utils.js,在项目中的eslint下对其进行重构。


utils.js
 /* 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负责图标和文本每分钟显示节拍的颜色。


 @mixin heart-rate-gradient { background: -webkit-linear-gradient(#f34193, #8f48ed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } 

原来是这样的:


图片



有趣的是-使用了本地CSS变量,但使用了SCSS的mixins
整个页面是CSS Grid


 display: grid; grid-gap: 1rem; height: 100vh; grid-template-rows: 1fr 1fr; grid-template-areas: "first" "second"; 

flexbox一样,父容器必须具有某种display 。 在这种情况下,它是grid
grid-gap columnsrows之间的一种间隔。
height: 100vh整个viewport的高度, fr必须在整个高度上占据空间(我们应用程序的2个部分)。
grid-template-rows定义我们的模板, fr是一个糖单位,它考虑了grid-gap和其他影响大小的属性。
grid-template-areas -在我们的示例中,仅是语义。


Chrome尚未提供用于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)); }, 

别忘了,这仅适用于chrome,仅适用于android上的chrome :)


接下来,添加一个图表,我们将使用Chart.jsChart.js的包装器


 npm install vue-chartjs chart.js --save 

极地有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


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> 

我们的应用程序已准备就绪。 但是,为了不跳到屏幕前面并进入第五级,让我们添加一个按钮,该按钮将为我们生成所有五级的随机数据:


 // 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蓝牙API非常简单。 有时需要使用按位运算符读取数据,但这是一个特定的领域。 当然,缺点是支持。 目前只有Chrome,而在手机上只有chrome。



Github资源
演示版

Source: https://habr.com/ru/post/zh-CN414357/


All Articles