Практическое руководство по созданию проектов IoT с подключением BLE

В этом руководстве мы познакомимся с миром разработки приложений для iOS и программирования Arduino, создав простую систему, которая позволит нам управлять электронной платой без проводов. Используя возможности подключения Bluetooth с низким энергопотреблением (BLE), мы создадим мобильное приложение, которое позволит нам взаимодействовать с платой Arduino Nano 33 BLE Sense, специально контролируя состояние светодиода и считывая данные о температуре с датчика. Это практическое руководство проведет вас через пошаговый процесс разработки приложения для iOS и скетча Arduino, снабдив вас ценными навыками для создания собственных пользовательских проектов Интернета вещей.

Nano 33 BLE Sense — это компактная плата Arduino, разработанная для проектов, требующих подключения BLE и возможностей распознавания. Он оснащен различными датчиками, что делает его подходящим для приложений IoT, носимых устройств и проектов сбора данных.

Понимание связи BLE

BLE — это технология беспроводной связи, разработанная для маломощных устройств. Он использует архитектуру клиент-сервер, в которой устройства могут действовать как:

  • Центральный (Клиент): он инициирует и контролирует связь.
  • Периферийное (серверное): обеспечивает доступ к данным или операциям.

Услуги и характеристики являются строительными блоками связи BLE:

  • Служба представляет собой набор связанных функций или данных.
  • Характеристика – это конкретное значение данных в службе.

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

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

В нашем проекте iOS-приложение будет выступать в качестве центрального, а плата Arduino — в качестве периферийного устройства. Плата будет предоставлять свои данные через две характеристики в рамках двух отдельных сервисов:

  • Характеристика состояния светодиодаСлужбе светодиодов): это характеристика со свойством записи, которое позволяет приложению изменять состояние светодиода, записывая значение 0 (Выкл. ) или 1 (Вкл.).
  • Температурная характеристикаSensor Service): это характеристика со свойствами чтения и уведомления, которая позволяет приложению получать значение температуры, измеренное платой, и получать уведомления о температуре. обновления.

Собираем программу для Ардуино.

Программа Arduino использует библиотеки ArduinoBLE и Arduino_HTS221, чтобы обеспечить связь BLE и функции измерения температуры.

#include <ArduinoBLE.h>
#include <Arduino_HTS221.h>

Во-первых, скетч настраивает необходимые службы и характеристики BLE, используя UUID, определяя свойства для каждой характеристики. UUID (универсальные уникальные идентификаторы) — это уникальные идентификаторы, используемые для идентификации услуг и характеристик в протоколе связи BLE.

BLEService ledService("cd48409a-f3cc-11ed-a05b-0242ac120003");
BLEByteCharacteristic ledstatusCharacteristic("cd48409b-f3cc-11ed-a05b-0242ac120003", BLEWrite);

BLEService sensorService("d888a9c2-f3cc-11ed-a05b-0242ac120003");
BLEByteCharacteristic temperatureCharacteristic("d888a9c3-f3cc-11ed-a05b-0242ac120003", BLERead | BLENotify);

Далее программа инициализирует модуль BLE и устанавливает локальное имя и рекламируемый сервис. Локальное имя, определяемое пользователем, передается Arduino и служит удобочитаемым идентификатором периферийного устройства. Установив для рекламируемой службы значение Led Service, Arduino информирует приложение iOS или другие центральные устройства о конкретной услуге, которую он предоставляет.

void setup() {
 // ...
 
 // BLE initialization
 if (!BLE.begin()) {
   while (1);
 }
 
 // set advertised local name and service UUID
 BLE.setLocalName("iOSArduinoBoard");
 BLE.setAdvertisedService(ledService);

Сервисы и характеристики добавляются в стек BLE, а для температурной характеристики устанавливается обработчик запросов на чтение.

 // add the characteristics to the services
 ledService.addCharacteristic(ledstatusCharacteristic);
 sensorService.addCharacteristic(temperatureCharacteristic);
 
 // add services to BLE stack
 BLE.addService(ledService);
 BLE.addService(sensorService);
 
 // set read request handler for temperature characteristic
 temperatureCharacteristic.setEventHandler(BLERead, temperatureCharacteristicRead);

Затем программа запускает рекламу и входит в основной цикл.

 // start advertising
 BLE.advertise();
}

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

void loop() {  
  // listen for BLE centrals to connect
  BLEDevice central = BLE.central();

  // if a central is connected to peripheral
  if (central) {
    // ...
    while (central.connected()) {
      // ...
    }
  }
}

В рамках этого цикла программа периодически считывает температуру с датчика и обновляет значение температурной характеристики. Кроме того, он проверяет, записало ли центральное устройство новое значение в характеристику LedStatus. Если обнаружено новое значение, он соответственно включает или выключает светодиод.

while (central.connected()) {
 // ...
 // read temperature value
 temperature = (int) HTS.readTemperature();
 temperatureCharacteristic.writeValue(temperature);
  
  // ...
  // check LedStatus characteristic write
  if (ledstatusCharacteristic.written()) {
    if (ledstatusCharacteristic.value()) {
      digitalWrite(LED_BUILTIN, HIGH);
    } else {
      digitalWrite(LED_BUILTIN, LOW);
    }
  }
}

Наконец, реализована функция обработчика событий чтения для ответа на запросы чтения температурной характеристики. Эта функция записывает текущее значение температуры в характеристику.

void temperatureCharacteristicRead(BLEDevice central, BLECharacteristic characteristic) {
 temperatureCharacteristic.writeValue(temperature);
}

Разработка iOS-приложения

Приложение для iOS разработано с удобным интерфейсом, состоящим из двух основных экранов:

  • Экран сканирования: он действует как начальный экран при запуске приложения, позволяя пользователям начать поиск периферийных устройств. Он отображает список обнаруженных периферийных устройств, и, нажав на имя периферийного устройства, пользователи могут инициировать соединение. После успешного подключения приложение переключается на экран подключения.
  • Экран подключения: предоставляет интерфейс для взаимодействия с подключенным Arduino. Пользователи могут изменить состояние светодиода, включив или выключив его. Кроме того, пользователи могут считывать температуру в двух режимах: однократное считывание (Чтение) или непрерывный мониторинг (Уведомление) об изменениях.

Приложение основано на архитектуре, вдохновленной чистой архитектурой с шаблоном проектирования MVVM (Model-View-ViewModel), с некоторыми изменениями для повышения простоты и легкости понимания. Этот подход способствует независимости компонентов и тестируемости за счет организации приложения на 3 отдельных уровня:

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

Приложение будет использовать CoreBluetooth для обработки всех операций, связанных с Bluetooth. CoreBluetooth — это платформа, предоставленная Apple, которая обеспечивает беспрепятственную связь с устройствами Bluetooth на платформах iOS. Он предлагает полный набор функций, которые упрощают реализацию функций Bluetooth.

Используя CoreBluetooth, приложение сможет обнаруживать ближайшие периферийные устройства Bluetooth, устанавливать соединения и обмениваться данными с устройствами.

Теперь пришло время заняться программированием iOS!
Прежде чем мы углубимся в эту тему, необходимо иметь несколько основных инструментов: компьютер Mac и Xcode, официальную интегрированную среду разработки (IDE) для платформ Apple. Xcode предлагает полный набор инструментов и ресурсов, которые позволяют нам проектировать, кодировать и отлаживать наши приложения для iOS.

Откройте Xcode, выберите «Создать новый проект Xcode» на экране приветствия и выберите шаблон приложения в разделе iOS. Укажите уникальное название продукта (iOSArduinoBLE), выберите идентификатор команды (ваше имя Apple ID), выберите язык (Swift) и место для сохранения вашего проекта.

…и вот оно! Давайте начнем.

Приложение для iOS: сканирование и подключение к плате Arduino

На экране сканирования пользователям предоставляется чистый и интуитивно понятный пользовательский интерфейс, который позволяет им обнаруживать устройства BLE и подключаться к ним.

Базовая архитектура этого экрана состоит из:

  • ScanView: отвечает за отображение пользовательского интерфейса на экране. Он разработан с использованием SwiftUI.
  • ScanViewModel: обрабатывает такие события, как нажатие кнопки «Начать сканирование» и выбор имени устройства для установления соединения. Для выполнения операций сканирования и подключения ScanViewModel использует файл CentralUseCase.
  • CentralUseCase: он инкапсулирует необходимую логику для взаимодействия с инфраструктурой CoreBluetooth, используя объект CBCentralManager. Который отвечает за инициирование сканирования, установление соединений и управление периферийными устройствами внутри фреймворка.

Уровень данных состоит из двух объектов: периферийных устройств и UUID. Объект Peripheral представляет устройство BLE и содержит такие сведения, как имя устройства, идентификатор и другие соответствующие свойства. UUID — это коллекция, содержащая список сервисов и характеристик, доступных для использования в приложении.

Сканирование

После нажатия кнопки «Начать сканирование» ScanView фиксирует действия пользователя и перенаправляет запрос в ScanViewModel для дальнейшей обработки.

//  ScanView.swift
// ...
Button {
    viewModel.scan()
} label: {
    Text("Start Scan")
    .frame(maxWidth: .infinity)
}
// ...

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

//  ScanViewModel.swift
// ...
func scan() {
    useCase.scan(for: [UUIDs.ledService])
}

Наконец, вариант использования взаимодействует с инфраструктурой CoreBluetooth, чтобы инициировать процедуру сканирования.

//  CentralUseCase.swift
// ...
lazy var central: CBCentralManager = {
 CBCentralManager(delegate: self, queue: DispatchQueue.main)
}()

func scan(for services: [CBUUID]) {
 guard central.isScanning == false else {
     return
 }
 central.scanForPeripherals(withServices: services, options: [:])
}

При использовании функции scanForPeripherals в CoreBluetooth вы можете обрабатывать ответ с помощью методов CBCentralManagerDelegate. Методом делегата для обработки обнаруженных периферийных устройств является didDiscover, который вызывается всякий раз, когда периферийное устройство обнаруживается в процессе сканирования.

Когда устройство обнаружено, CentralUseCase информирует ScanViewModel, вызывая замыкание onPeripheralDiscovery (т. е. автономный блок кода) и предоставляя ему сведения о недавно найденном периферийном устройстве.

//  CentralUseCase.swift
// ...
extension CentralUseCase: CBCentralManagerDelegate {
  func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
                       advertisementData: [String : Any], rssi RSSI: NSNumber) {
      onPeripheralDiscovery?(.init(cbPeripheral: peripheral))
  }
 // ...
}

ScanViewModel обновляет свое внутреннее состояние и уведомляет ScanView о том, что новое устройство готово для отображения в списке периферийных устройств.

//  ScanViewModel.swift
// ...
useCase.onPeripheralDiscovery = { [weak self] peripheral in
    guard let self = self else {
        return
    }
    self.foundPeripherals.insert(peripheral)
    self.state = .scan(Array(self.foundPeripherals))
}
//  ScanView.swift
// ...
VStack{
 List(peripheralList, id: \.id) { peripheral  in
  Text("\(peripheral.name ?? "N/A")")
   // ...
   }
 }
// ...
}
.onReceive(viewModel.$state) { state in
    switch state {
    // ...
      case .scan(let list):
        peripheralList = list
    // ...
    }
}

Подключение

Как только устройство в списке нажато, ScanView фиксирует взаимодействие с пользователем и перенаправляет запрос в ScanViewModel, предоставляя ему периферийное устройство для подключения.

//  ScanView.swift
// ...
List(peripheralList, id: \.id) { peripheral  in
    Text("\(peripheral.name ?? "N/A")")
        // ...
        .onTapGesture {
            viewModel.connect(to: peripheral)
        }
}

Это действие запускает ScanViewModel для перенаправления операции подключения в соответствующий вариант использования.

//  ScanViewModel.swift
// ...
func connect(to peripheral: Peripheral) {
 useCase.connect(to: peripheral)
}

Наконец, вариант использования взаимодействует с инфраструктурой CoreBluetooth, останавливая процесс сканирования и инициируя процедуру подключения к выбранному устройству.

//  CentralUseCase.swift
// ...
func connect(to peripheral: Peripheral) {
 central.stopScan()
 central.connect(peripheral.cbPeripheral!)
}

Если соединение установлено успешно, будет вызван метод делегата didConnect. CentralUseCase передает эту информацию модели ScanViewModel, вызывая замыкание onConnection.

//  CentralUseCase.swift
// ...
func centralManager(_ central: CBCentralManager,
                    didConnect peripheral: CBPeripheral) {
 onConnection?(.init(cbPeripheral: peripheral))
}

ScanViewModel обновляет свое внутреннее состояние и уведомляет ScanView об установлении соединения с выбранным устройством.

//  ScanViewModel.swift
// ...
useCase.onConnection = { [weak self] peripheral in
 self?.state = .connected(peripheral)
}

Соответственно, ScanView переходит к переключению на ConnectView.

//  ScanView.swift
// ...
.onReceive(viewModel.$state) { state in
      switch state {
      case .connected:
          shouldShowDetail = true
   // ...
      }
  }
  .navigationDestination(isPresented: $shouldShowDetail) {
      if case let .connected(peripheral) = viewModel.state  {
          let viewModel = ConnectViewModel(useCase: PeripheralUseCase(),
              connectedPeripheral:peripheral)
          ConnectView(viewModel: viewModel)
      }
  }

Приложение для iOS: запись и чтение данных

На экране «Подключение» пользователи могут взаимодействовать с Arduino и обмениваться информацией, касающейся светодиода и температуры.

Архитектура остается неизменной с предыдущим экраном и включает следующие компоненты:

  • ConnectView: представляет элементы пользовательского интерфейса для управления состоянием светодиода, считывания информации о температуре и отключения от устройства. Он реализован с помощью SwiftUI.
  • ConnectViewModel: фиксирует действия пользователя, такие как нажатие кнопок включения/выключения для управления светодиодами, переключение включения уведомления о температуре, нажатие кнопки чтения для мгновенных показаний температуры и срабатывание кнопки отключения. Он связывается с PeripheralUseCase для обработки этих операций.
  • PeripheralUseCase. Подобно CentralUseCase, он отвечает за логику, связанную с CoreBluetooth. Он взаимодействует с объектом CBPeripheral, который отвечает за управление и обмен данными через характеристики и сервисы.

Прежде чем инициировать обмен данными с сервисами и характеристиками, необходимо выполнить фазу обнаружения. На этом этапе платформа CoreBluetooth сканирует и извлекает информацию о доступных услугах и характеристиках, предлагаемых подключенным периферийным устройством.

Открытие

После создания ConnectViewModel и связанного с ним PeripheralUseCase инициируется процедура служб обнаружения. Для каждой обнаруженной услуги также обнаруживаются соответствующие характеристики.

Ответы от платформы CoreBluetooth доставляются соответствующим методам делегата: didDiscoverServices и didDiscoverCharacteristicsFor. Эти методы предоставляют приложению информацию об услугах и характеристиках устройства.

//  PeripheralUseCase.swift
// ...
func discoverServices() {
 cbPeripheral?.discoverServices([UUIDs.ledService, UUIDs.sensorService])
}
// ...
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
 // ...
  for service in services {
      // ...
      peripheral.discoverCharacteristics(uuids, for: service)
  }
}

После завершения процесса обнаружения ConnectViewModel уведомляется с помощью onPeripheralReady, и пользовательский интерфейс готов к обработке операций с обнаруженными характеристиками.

//  PeripheralUseCase.swift
// ...
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
  for characteristic in characteristics {
      discoveredCharacteristics[characteristic.uuid] = characteristic
  }

  if discoveredCharacteristics[UUIDs.temperatureCharacteristic] != nil &&
      discoveredCharacteristics[UUIDs.ledStatusCharacteristic] != nil {
      onPeripheralReady?()
  }
}

Светодиод управления

Когда пользователь нажимает кнопки включения/выключения, запускается операция записи в характеристику LedStatus с числовым значением (1 для «Вкл.» и 0 для «Выкл.»). Это действие, в свою очередь, управляет встроенным светодиодом на плате, включая или выключая его соответственно.

Весь процесс управляется с использованием обычного потока:

//  ConnectView.swift
// ...
Button("On") {
    viewModel.turnOnLed()
}
// ...
Button("Off") {
    viewModel.turnOffLed()
}

//  ConnectViewModel.swift
// ...
func turnOnLed() {
  useCase.writeLedState(isOn: true)
}

func turnOffLed() {
  useCase.writeLedState(isOn: false)
}

//  PeripheralUseCase.swift
// ...
func writeLedState(isOn: Bool) {
  cbPeripheral?.writeValue(Data(isOn ? [0x01] : [0x00]), for: ledCharacteristic, type: .withResponse)
}

Температура чтения

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

Поток запроса операции чтения выглядит следующим образом:

//  ConnectView.swift
// ...
Button("READ") {
  viewModel.readTemperature()
}

//  ConnectViewModel.swift
// ...
func readTemperature() {
  useCase.readTemperature()
}

//  PeripheralUseCase.swift
// ...
func readTemperature() {
 cbPeripheral?.readValue(for: tempCharacteristic)
}

Включение/отключение потока операции уведомления выполняется по тому же шаблону:

//  ConnectView.swift
// ...
Toggle("Notify", isOn: $isToggleOn)
// ...
.onChange(of: isToggleOn) { newValue in
 if newValue == true {
  viewModel.startNotifyTemperature()
 } else {
  viewModel.stopNotifyTemperature()
 }
}

//  ConnectViewModel.swift
// ...
func startNotifyTemperature() {
  useCase.notifyTemperature(true)
}

func stopNotifyTemperature() {
  useCase.notifyTemperature(false)
}

//  PeripheralUseCase.swift
// ...
func notifyTemperature(_ isOn: Bool) {
 cbPeripheral?.setNotifyValue(isOn, for: tempCharacteristic)
}

Обе операции будут генерировать ответ для одного и того же делегата CoreBluetooth, в частности для метода didUpdateValueFor. Для операции чтения будет один ответ с запрошенными данными. Для уведомления ответы будут продолжать отправляться до тех пор, пока уведомление не будет отключено. Каждый ответ вызовет закрытие onReadTemperature в ConnectViewModel для соответствующего обновления состояния пользовательского интерфейса.

//  PeripheralUseCase.swift
// ...
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
  switch characteristic.uuid {
   case UUIDs.temperatureCharacteristic:
     let value: UInt8 = {
       guard let value = characteristic.value?.first else {
         return 0
       }
       return value
     }()
     onReadTemperature?(Int(value))
  }
}

//  ConnectViewModel.swift
// ...
useCase.onReadTemperature = { [weak self] value in
 self?.state = .temperature(value)
}

//  ConnectView.swift
// ...
@State var lastTemperature: Int = 0
// ...
Text("\(lastTemperature) °C")
// ...
.onReceive(viewModel.$state) { state in
  switch state {
   // ...
   case let .temperature(temp):
       lastTemperature = temp
  }
}

Отключение

Когда нажата кнопка Disconnect, поток принимает немного другое направление. Поскольку операция отключения принадлежит объекту CBCentralManager, она должна выполняться CentralUseCase. Чтобы включить это, когда пользователь нажимает кнопку, текущий экран закрывается, и пользователь возвращается к экрану сканирования.

//  ConnectView.swift
// ...
Button {
  dismiss()
} label: {
  Text("Disconnect")
  .frame(maxWidth: .infinity)
}

Когда появляется ScanView, первая операция, которую он выполняет, — это проверка, подключено ли уже устройство, и, если да, отключение его. Эта операция обрабатывается соответствующим ScanViewModel, который затем перенаправляет запрос в CentralUseCase для выполнения операции отключения в среде CoreBluetooth.

//  ScanView.swift
// ...
.onAppear {
  viewModel.disconnectIfConnected()
}

//  ScanViewModel.swift
// ...
func disconnectIfConnected() {
  guard case let .connected(peripheral) = state,
  peripheral.cbPeripheral != nil else {
      return
  }
  useCase.disconnect(from: peripheral)
}

//  CentralUseCase.swift
// ...
func disconnect(from peripheral: Peripheral) {
  central.cancelPeripheralConnection(peripheral.cbPeripheral!)
}

Заключение

В этой статье мы рассмотрели путь от программирования Arduino до разработки приложений для iOS с упором на создание проектов IoT с возможностью подключения BLE.

Мы узнали, как создать приложение для iOS с использованием Xcode и фреймворка CoreBluetooth. Мы изучили основы BLE, принципы чистой архитектуры и внедрили шаблон проектирования MVVM, чтобы обеспечить масштабируемую и поддерживаемую кодовую базу.

Этот проект может послужить хорошей отправной точкой для ваших проектов IoT. Однако прямое использование CoreBluetooth может иметь некоторые ограничения, такие как сложная обработка фоновых операций и отсутствие высокоуровневых функций (Mesh, обновление встроенного ПО и т. д.). Чтобы преодолеть эти ограничения, рекомендуется использовать сторонние библиотеки, которые предлагают дополнительные уровни абстракции и поддержку расширенных функций.
Некоторые популярные варианты включают в себя:

  • RxBluetoothKit: мощная библиотека BLE на основе ReactiveX.
  • Bluejay: современная, удобная библиотека BLE, которая подчеркивает простоту и удобство использования.
  • LittleBlueTooth: легкая и простая библиотека BLE.

Код