Русский
Русский
English
Статистика
Реклама

Подключаем нагрудный датчик пульса по Bluetooth на Swift

С чего все началось?

Около года назад я приобрел данный девайс для контроля частоты сердечных сокращений (далее - ЧСС) во время тренировок. Датчик отлично подсоединяется к телефону, умным часам по Bluetooth, но обычно, фитнесс-приложения, анализирующие подобного рода данные требуют либо подписки, либо нагружены излишне сложными аналитиками, которые мне, как рядовому пользователю не очень интересны. Поэтому у меня родилась идея написать свое приложение для контроля ЧСС во время тренировок для IOS на Swift.

Немного теории о технологии Bluetooth LE

Bluetooth Low Energy - очень популярный и распространённый протокол обмена данными, который мы используем повсеместно и который становится все популярнее с каждым днем. У меня даже чайник на кухне управляется дистанционно через BLE. Low energy, кстати, гораздо сниженное энергопотребление в отличие от "голого" Bluetooth, настолько сниженное, что устройство готово общаться по данному протоколу на одной батарейке несколько месяцев, а то и лет.

Конечно, цитировать и переписывать спецификацию протокола BLE 5.2 нет никакого смысла, поэтому ограничимся основными понятиями.

Центральное и периферийное устройство

В зависимости от использования и назначения, устройство Bluetooth может быть:

  • Центральным (главным) - получает данные от периферийного устройства (наш телефон)

  • Периферийным - устройство, которое отправляет данные на центральное устройство (датчик ЧСС)

Рекламные пакеты данных протокола

Рекламные или оповещательные данные отправляются с периферийного устройства в виде пакетов, которые содержат в себе основную информацию об устройстве: его название, а также его функциональные возможности. Задача центрального устройства, получить их, прочитать, а после выделить из списка периферии необходимый для подключения девайс.

Объем рекламных пакетов не очень большой и вместить всю информацию об устройстве не способен. Чтобы получить доступ ко всем возможностям и характеристикам устройства, необходимо выполнить подключение, после чего считать его данные, которые, в свою очередь, могут быть предоставлены в виде:

  • Сервиса (услуг) - набор данных, описывающих функции устройства. В нашем случае мы увидим службу получения ЧСС.

  • Характеристик - дополнительных описаний сервисов устройства. Например характеристика изменения сердечного ритма в секунду, а также положения датчика на теле.

Переходя к абстракциям, сервисом является некий шкаф, в котором много ящиков - характеристик. Причем каждый сервис уникален и представлен идентификатором UUID, который может быть 16-битным или 128-битным, в зависимости от типов сервисов.

Перейдем к написанию кода

Создадим проект в Xcode с одноимённым названием, после чего добавим несколько необходимых Label в Main.storyboard и перетянем outlets этих labels во View Controller, закрепим их с помощью constraints, а также скроем их для первоначального изображения в методе viewDidLoad, как я сделал это на изображении:

Я создал outlets для текстовых значений "121" и "грудь", другие же текстовые значения просто закрепил на view, так как изменений в них делать мы не планируем.

Отладку и демонстрацию работы необходимо совершать на реальном устройстве, так как симулятор не поддерживает возможность работы по протоколу Bluetooth.

В файле Info.plist проекта необходимо добавить свойство: Bluetooth Always Usage Description и прикрепить к нему описание, чтобы уведомить пользователя об использовании данных по Bluetooth при первом запуске приложения. Если данное свойство не добавить в список, то приложение "упадет" с одноименной ошибкой. Не забывайте про это!

Подключаем библиотеку Bluetooth

Тут все просто, для подключения библиотеки воспользуемся следующей строчкой:

import CoreBluetooth

Вспомним, что по протоколу у нас существуют так называемые центральные и периферийные устройства, логично предположить, что основной функционал работы данного протокола будет исполнен методами делегатов централи и периферии.

Для начала создадим переменную центрального (главного) устройства в проекте рядом с объявлением других переменных:

var centralManager: CBCentralManager!

Теперь, чтобы получить доступ к методам необходимо назначить ViewController делегатом, но предварительно подпишем его под протокол CBCentralManagerDelegate. Сделать это предлагаю в extension ViewController, так будет рациональнее.

extension ViewController: CBCentralManagerDelegate {}

Xcode на такое пользовательское действие отреагирует ошибкой: "Type 'ViewController' does not conform to protocol 'CBCentralManagerDelegate'", оповещая, что данный протокол требует обязательную реализацию метода: "func centralManagerDidUpdateState(_ central: CBCentralManager)". Нажмем "fix", добавив этот метод в проект. Данный метод нужен для автоматической проверки состояния центрального менеджера, которого мы создали ранее.

Чтобы отобразить все состояния центрального менеджера, в теле метода "func centralManagerDidUpdateState(_ central: CBCentralManager)" напишем:

 func centralManagerDidUpdateState(_ central: CBCentralManager) {        switch central.state {        }

Xcode автоматически предложит вставить все возможные состояния данного условия, соглашаемся с ним. А в каждом из состояний напишем функцию print("это состояние"):

   extension ViewController: CBCentralManagerDelegate {    func centralManagerDidUpdateState(_ central: CBCentralManager) {        switch central.state {        case .unknown:            print ("central.state is unknown")        case .resetting:            print ("central.state is resetting")        case .unsupported:            print ("central.state is unsupported")        case .unauthorized:            print ("central.state is unauthorized")        case .poweredOff:            print ("central.state is poweredOff")        case .poweredOn:            print ("central.state is poweredOn")        @unknown default:            break        }    }}

Теперь нам осталось проинициализировать переменную "centralManager" и задать ей делегирование. Сделаем это в методе "viewDidLoad", а в качестве параметра очереди напишем "nil", определяя всю работу про Bluetooth в главной очереди.

override func viewDidLoad() {        super.viewDidLoad()        centralManager = CBCentralManager(delegate: self, queue: nil)        heartRateLabel.isHidden = true        bodyLocationLabel.isHidden = true    }

Собираем проект, запускаем на устройстве с включенным Bluetooth, видим системный запрос за его использование, соглашаемся и получаем в консоль заветное сообщение "central.state is poweredOn", которое сигнализирует нам о том, что центральный менеджер готов к работе. Если выключить Bluetooth на телефоне, то в консоли появится логичное "central.state is poweredOff".

Поиск Bluetooth устройств

Центральный менеджер ждет дальнейших указаний, и сейчас он их получит. Для этого в методе "centralManagerDidUpdateState" в случае ".poweredOn" после метода "print" пишем:

centralManager.scanForPeripherals(withServices: nil)

Менеджер начнет сканировать все доступные вокруг устройства, а чтобы мы смогли увидеть их в консоли приложения, необходимо реализовать метод делегата в extension ViewController ниже метода "centralManagerDidUpdateState" следующим образом:

 func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {        print(peripheral)    }

Запускаем приложение... И теперь в консоли мы можем увидеть множество всех доступных для подключения устройств. Замечательно! Одним из них и является необходимый для подключения пульсометр. Но чтобы упростить поиск пульсометра, можно воспользоваться некоторой хитростью, которую я сейчас покажу.

Идентификатор служб UUID

Я ранее упомянул наличие данного идентификатора в протоколе Bluetooth как уникальную характеристику для различных устройств, поэтому могу сказать вам, что пульсометры обладают таким уникальным UUID для своей непосредственной службы измерения ЧСС. Список всех UUID можно также найти в спецификации, из которой я нашел нужный: "0x180D". Добавим новую константу в проект над объявленными ранее outlets:

let heartRateUUID = CBUUID(string: "0x180D")

Также обновим метод "centralManager.scanForPeripherals(withServices: nil)" добавив в него вышенаписанный идентификатор пульсометра:

case .poweredOn:            print ("central.state is poweredOn")            centralManager.scanForPeripherals(withServices: [heartRateUUID] )

Теперь центральный менеджер находится в поиске устройств с данным UUID, и после некоторого времени в консоли появиться заветное устройство:

<CBPeripheral: 0x280214000, identifier = D5A5CD3E-33AC-7245-4294-4FFB9B986DFC, name = COOSPO H6 0062870, state = disconnected>

Теперь необходимо создать переменную в проекте, с которой мы сможем связать данное устройство, для этого рядом с "var centralManager: CBCentralManager!" напишем:

var heartRatePeripheral: CBPeripheral!

А в методе "didDiscover peripheral" свяжем найденное устройство с вышеобъявленной переменной и прекратим поиск новых устройств с помощью метода:

 func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {        print(peripheral)        heartRatePeripheral = peripheral        centralManager.stopScan()    }

Подключаемся к пульсометру

Для этого напишем под строкой "centralManager.stopScan()":

centralManager.connect(heartRatePeripheral, options: nil)

Нам уже удалось подключиться к пульсометру, но чтобы это действительно увидеть, необходимо реализовать еще один метод делегата "didConnect peripheral" ниже метода "didDiscover peripheral", который автоматически вызывается при подключении нового устройства:

func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {        print("Соединение установлено")    }

Собираем проект, запускаем на устройстве и видим в консоле заветное "Соединение установлено". Хороший результат, теперь двигаемся дальше.

Получаем список сервисов с пульсометра

После того, как соединение установлено, необходимо сделать запрос об услугах (сервисах), которые данный пульсометр готов предоставить. Для этого после установки соединения вызовем метод "heartRatePeripheral.discoverServices()" в методе "didConnect", который примет следующий вид:

func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {        print("Соединение установлено")        heartRatePeripheral.discoverServices(nil)    }

Запрос на получение сервисов сделан, а чтобы их увидеть и начать с ними работать, необходимо расширить класс протоколом "CBPeripheralDelegate" в самом низу нашего проекта и вызвать метод "peripheral(_:didDiscoverServices:)" следующим образом:

extension ViewController: CBPeripheralDelegate {        func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {        guard let services = peripheral.services else { return }        for service in services {            print(service)        }    }}

Метод получает сервисы, сообщает об этом центральному менеджеру и выводит их в консоль. В данный момент консоль будет пуста, так как необходимо делегировать данный протокол периферийному устройству "heartRatePeripheral". Сделаем это после инициализации периферийного устройства в следующем методе:

  func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {        print(peripheral)        heartRatePeripheral = peripheral                heartRatePeripheral.delegate = self                centralManager.stopScan()        centralManager.connect(heartRatePeripheral, options: nil)    }

Отлично, делегат обьявлен, метод получения сервисов написан, запустим программу на телефоне и получим в консоль список служб пульсометра:

<CBService: 0x2824b4340, isPrimary = YES, UUID = Heart Rate>

<CBService: 0x2824b4240, isPrimary = YES, UUID = Battery>

<CBService: 0x2824b4280, isPrimary = YES, UUID = Device Information>

<CBService: 0x2824b4200, isPrimary = YES, UUID = 8FC3FD00-F21D-11E3-976C-0002A5D5C51B>

Не все сервисы нам интересны и оставить необходимо лишь первый. Для этого можно провести так называемую фильтрацию с помощью идентификатора UUID в методе "heartRatePeripheral.discoverServices()"

heartRatePeripheral.discoverServices([heartRateUUID])

Вот теперь список служб отобразится в виде "<CBService: 0x2824b4340, isPrimary = YES, UUID = Heart Rate>", из которой мы сможем извлечь нужные нам характеристики - ящики ( шкафа мы уже получили).

Достаем характеристики из шкафа

Шкаф-сервис нам известен, осталось посмотреть, что он предлагает и получить это. Сделаем запрос на получение характеристик, для этого в теле метода "didDiscoverServices - peripheral" реализуем метод - поиск:

extension ViewController: CBPeripheralDelegate {        func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {        guard let services = peripheral.services else { return }        for service in services {            peripheral.discoverCharacteristics(nil, for: service)        }    }}

Теперь доступный сервис будет посылать свои характеристики, а увидеть мы их сможем в самостоятельном методе делегата "CBPeripheralDelegate" под названием "didDiscoverCharacteristicsFor". Реализуем его и выведем в консоль все доступные характеристики:

func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {        guard let characteristics = service.characteristics else { return }        for characteristic in characteristics {            print(characteristic)        }    }

Запускаем программу, видим, что характеристики получены, а консоль заполнилась следующими строками:

<CBCharacteristic: 0x28024c120, UUID = 2A37, properties = 0x10, value = {length = 2, bytes = 0x0469}, notifying = NO>

<CBCharacteristic: 0x28024c180, UUID = 2A38, properties = 0x2, value = {length = 1, bytes = 0x01}, notifying = NO>

Видно, что у данной службы две характеристики, имеющие два уникальных идентификатора. Из спецификации на Bluetooth узнаем, что UUID = 2A37 отвечает за измерение ЧСС, а UUID = 2A38 за положение датчика на теле. Положение датчика на теле не самая интересная характеристика в данной теме, но будет полезно считать и ее.

Для удобства добавим в проект два уникальных идентификатора данных характеристик следующим образом:

 let heartRateUUID = CBUUID(string: "0x180D") let heartRateCharacteristicCBUUID = CBUUID(string: "2A37") let bodyLocationCharacteristicCBUUID = CBUUID(string: "2A38")

Характеристики отличаются друг от друга типами свойств. Например, характеристика ЧСС имеет свойство ".notify" т.е. она уведомляет об изменении значения ЧСС, а характеристика положения на теле имеет свойство ".read", т.е. может быть считана напрямую. Данное пояснение необходимо, чтобы правильно получить значения из них.

Положение пульсометра на теле

Характеристика выведена консоль, теперь нужно лишь реализовать метода считывая значений из нее. Для этого напишем запрос на чтение значений "peripheral.readValue(for: characteristic)"

 func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {        guard let characteristics = service.characteristics else { return }        for characteristic in characteristics {            peripheral.readValue(for: characteristic)        }    }

Запрос написан, как вы догадываетесь, нужно реализовать еще один метод "peripheral(_:didUpdateValueFor:error:)" делегата "CBPeripheralDelegate", который будет в асинхронном режиме получать ответ с данного запроса, причем в данном методе напишем конструкцию "switch - case", чтобы была возможность разделить характеристики по уникальному идентификатору:

func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic,                error: Error?) {  switch characteristic.uuid {    case bodySensorLocationCharacteristicCBUUID:      print(characteristic.value ?? "no value")    default:      print("Unhandled Characteristic UUID: \(characteristic.uuid)")  }}

В консоли после выполнения данной программы появится строка "1 bytes". Это нужный результат, потому что мы пытались вывести объект типа "data".

Чтобы "распасить" данный байт, необходимо снова прибегнуть к спецификации, из которой мы сможем понять, как данный байт заполнен. Ускоряя результат, реализуем ниже функцию получения строки положения на теле датчика ЧСС из этого байта:

      private func bodyLocation(from characteristic: CBCharacteristic) -> String {        guard let characteristicData = characteristic.value,              let byte = characteristicData.first else { return "Error" }        switch byte {        case 0: return "Другое"        case 1: return "Грудь"        case 2: return "Запястье"        case 3: return "Палец"        case 4: return "Ладонь"        case 5: return "Мочка уха"        case 6: return "Нога"        default:            return "Резерв"        }    }

И теперь вызовем данную функцию в методе "didUpdateValueFor characteristic", одновременно выводя результат на экран телефона (не забудем показать скрытый label для положения датчика):

   func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic,                    error: Error?) {                switch characteristic.uuid {                case bodyLocationCharacteristicCBUUID:            let bodySensorLocation = bodyLocation(from: characteristic)            bodyLocationLabel.text = bodySensorLocation            bodyLocationLabel.isHidden = false                  default:          print("Unhandled Characteristic UUID: \(characteristic.uuid)")      }            }

Ура! Характеристика успешно получена, прочитана и выведена на экран!

Не совсем ясно, где еще можно носить данный пульсометр, поэтому существует данная характеристика :)


Получение ЧСС и вывод на экран пользователя

Осталось совсем немного, и теперь нужно получить значения из характеристики ЧСС. Как мы помним, у нее тип значения ".notify", поэтому нам нужно как бы "подписаться на нее", чтобы она присылала обновленные значения ЧСС. Для этого нужно выполнить метод "peripheral.setNotifyValue(true, for: characteristic)" в функции "didDiscoverCharacteristicsFor service:

func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {        guard let characteristics = service.characteristics else { return }        for characteristic in characteristics {            peripheral.readValue(for: characteristic)            peripheral.setNotifyValue(true, for: characteristic)        }    }

Если запустить приложение, то в консоли появятся стоки:

Unhandled Characteristic UUID: 2A37

Unhandled Characteristic UUID: 2A37

Unhandled Characteristic UUID: 2A37

Именно в этой характеристики и лежат данные о ЧСС. Теперь необходимо провернуть такую же развертку этих данных, обращаясь к спецификации. В некоторых моделях данные могут быть представлены либо 1 либо 2 байтами. Чтобы не получить конфуз, реализуем метод для "парсинга" этих данных в нужном порядке в протоколе "CBPeripheralDelegate".

  private func heartRate(from characteristic: CBCharacteristic) -> Int {        guard let characteristicData = characteristic.value else { return -1 }        let byteArray = [UInt8](characteristicData)                let firstBitValue = byteArray[0] & 0x01        if firstBitValue == 0 {            return Int(byteArray[1])        } else {            return (Int(byteArray[1]) << 8) + Int(byteArray[2])        }    }

И, наконец, добавим еще один case в методе "peripheral(_:didUpdateValueFor:error:)", в котором получим ЧСС, а также обновим и покажем label пользовательского интерфейса:

   func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic,                    error: Error?) {                switch characteristic.uuid {                case bodyLocationCharacteristicCBUUID:            let bodySensorLocation = bodyLocation(from: characteristic)            bodyLocationLabel.text = bodySensorLocation            bodyLocationLabel.isHidden = false                    case heartRateCharacteristicCBUUID:            let bpm = heartRate(from: characteristic)            heartRateLabel.text = String(bpm)            heartRateLabel.isHidden = false                    default:          print("Unhandled Characteristic UUID: \(characteristic.uuid)")      }    }

Поздравляю!

Теперь данные с пульсометра выводятся на экран телефона. Я даже слегка нервничаю :)


Итоги

В целом гайд по использованию Bluetooth для подключения датчика ЧСС вышел немного большим и местами сложным, надеюсь, что основной смысл мне удалось донести. Конечно, есть еще несколько нереализованных методов, которые можно было бы добавить (например, метод переподключения при обрыве соединения), но я посчитал этого набора достаточным, чтобы в меру оценить лаконичность и удобность библиотеки на swift CoreBluetooth.

Всем успехов и спасибо!

Источник: habr.com
К списку статей
Опубликовано: 02.04.2021 14:12:19
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Разработка под ios

Разработка мобильных приложений

Swift

Bluetooth

Corebluetooth

Ios

Пульсометр

Мобильная разработка

Датчик пульса

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru