نحن نستخدم Web Bluetooth API لتوصيل جهاز مراقبة معدل ضربات القلب وتطوير التطبيق باستخدام Vue.js

نواصل مناقشة المواضيع التي نوقشت في مؤتمر You Gonna Love Frontend . هذه المقالة مستوحاة من محاضرة ميكايلا لير . ستكون مقاطع الفيديو الخاصة بالمؤتمر متاحة هذا الأسبوع طالما أن هناك شرائح . ( الفيديو متاح بالفعل )



قامت Michaela Lehr بتوصيل الهزاز بالمتصفح باستخدام Web APIs ، وهي Web Bluetooth API. حركة المرور Prosniferif بين التطبيق والهزاز ، وجدت أن الأوامر المرسلة بسيطة للغاية ، على سبيل المثال: vibrate: 5 . ثم بعد أن علمته الاهتزاز على صوت الآهات من مقطع فيديو يمكنها العثور عليه على الإنترنت ، حققت أهدافها :)


ليس لدي مثل هذه الألعاب ، والتصميم غير مخصص للاستخدام ، ولكن هناك جهاز مراقبة معدل ضربات القلب 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) التي تبدو كما نحتاج.


لتوصيل الجهاز بالمتصفح ، يجب على المستخدم التفاعل بوعي مع واجهة المستخدم. لذا ، بالطبع ، الأمان ، مع الأخذ في الاعتبار أنه عند الدخول إلى صالة الألعاب الرياضية ، يتصل جهاز مراقبة معدل ضربات القلب بصمت بكل ما أريده (في وقت واحد فوجئت بهذا). شكرا بالطبع لمطوري المتصفح ، ولكن لماذا ؟! حسنًا ، إنها ليست صعبة وليست مقرفة جدًا.


دعنا نضيف زرًا ومعالجًا إلى الصفحة في نص <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:




بعد العثور على الجهاز الذي نحتاجه ، من الضروري قراءة البيانات منه. للقيام بذلك ، قم بتوصيله بخادم الجات:
 navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] }) .then((device) => { return device.gatt.connect(); }) 

البيانات التي نريد قراءتها موجودة في خصائص الخدمة. تحتوي أجهزة مراقبة معدل ضربات القلب على 3 خصائص فقط ، ونحن مهتمون بالمؤسسة. 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 وظيفة تُستخدم لتحليل البيانات ، ويمكن العثور على مواصفات البيانات هنا - 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 (الرسوم المتحركة النبضية ليست ثابتة).


رسم


أنا أحب التصميم الجميل ، لكن المصمم لطيف معي.
ليس لدي Photoshop ، سنبتعد بطريقة ما عن الطريق.
أولاً ، لنقم بإنشاء مشروع Vue.js جديد باستخدام Vue-cli


 vue create heart-rate 

اخترت الإعدادات اليدوية وتبدو صفحة الإعدادات الأولى كما يلي:



بعد ذلك ، اختر بنفسك ، ولكن لدي تكوين Airbnb و Jest و Sass.


لقد نظرت في نصف دروس Wes Bos CSS Grids التعليمية ، أوصي أنها مجانية.
حان الوقت للقيام بالتخطيط الأولي. لن نستخدم أي أطر عمل CSS ، كلها خاصة بنا. بالطبع ، لا نفكر في الدعم أيضًا.


البومة رسم السحر


لذا ، أولاً وقبل كل شيء ، دعنا نحدد layout . في الواقع ، سيتألف التطبيق من جزأين. سوف نطلق عليهم ذلك - first second . في الجزء الأول سيكون لدينا تمثيل رقمي (نبضة في الدقيقة) ، في الرسم البياني الثاني.
قررت سرقة مخطط الألوان من هنا .



نطلق تطبيق Vue إذا لم تكن قد قمت بذلك بالفعل:


 npm run serve 

ستفتح الأداة نفسها المتصفح (أو لا) ، وهناك إعادة تحميل سريعة ورابط للاختبار الخارجي. قمت على الفور بوضع هاتف محمول بجانبي ، لأننا نفكر في التصميم الأول للجوال. لسوء الحظ ، أضفت PWA إلى القالب ، وعلى الهاتف المحمول ، يتم تنظيف ذاكرة التخزين المؤقت عند إغلاق المتصفح ، ولكن يحدث ، ويتم تحديث موافق للحفظ. بشكل عام ، لحظة غير مفهومة لم أفهمها.


أولاً ، قم بإضافة utils.js ، من خلال utils.js الخاصة بتحليل القيم ، وإعادة هيكلتها قليلاً تحت eslint في المشروع.


يستخدم. 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 ، سيكون هذا المكون مسؤولًا عن عرض 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 :


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 ، بينما يوجد mixins.scss واحد فقط هو المسؤول عن لون الرمز والنص الذي يعرض نبضات في الدقيقة.


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

اتضح مثل هذا:


صور



من بين النقاط المثيرة للاهتمام - يتم استخدام متغيرات CSS الأصلية ، ولكن 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 بأكمله ، من الضروري أن تشغل مساحة بارتفاع كامل (جزءان من تطبيقنا).
grid-template-rows - حدد قالبنا ، fr هي وحدة سكر تأخذ في الاعتبار grid-gap وغيرها من الخصائص التي تؤثر على الحجم.
grid-template-areas - في مثالنا ، دلالي فقط.


لم يقدم Chrome حتى الآن أدوات عادية لفحص شبكات CSS:



في نفس الوقت في إفشل:



نحتاج الآن إلى إضافة معالج النقر بزر ، على غرار الطريقة التي قمنا بها من قبل.
أضف معالج:


 // 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)); }, 

لا تنس أن هذا يعمل فقط في الكروم وفقط في الكروم على android :)


بعد ذلك ، أضف مخططًا ، وسوف نستخدم 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 منفصلة 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 Bluetooth API أمرًا بسيطًا للغاية. هناك لحظات تحتاج إلى قراءة البيانات باستخدام عوامل تشغيل أحادي البت ، ولكن هذه منطقة محددة. من السلبيات بالطبع هو الدعم. في الوقت الحالي هو الكروم فقط ، والهواتف المحمولة كروم وفقط على الروبوت.



مصادر جيثب
تجريبي

Source: https://habr.com/ru/post/ar414357/


All Articles