From Arduino programming to iOS App development

Hands-on guide to building IoT projects with BLE connectivity

Leonardo Cavagnis
13 min readMay 24, 2023

In this tutorial, we will explore the world of iOS app development and Arduino programming by creating a simple system that enables us to control an electronic board wirelessly. By leveraging the capabilities of Bluetooth Low Energy (BLE) connectivity, we will build a mobile application that allows us to interact with an Arduino Nano 33 BLE Sense board, specifically controlling the state of a LED and reading temperature data from a sensor. This hands-on guide will take you through the step-by-step process of developing the iOS app and the Arduino sketch, equipping you with valuable skills to create your own custom IOT projects.

System overview. Image by author.

The Nano 33 BLE Sense is a compact Arduino board designed for projects requiring BLE connectivity and sensing capabilities. It features a variety of sensors, making it suitable for IoT applications, wearables, and data acquisition projects.

Understanding the BLE communication

BLE is a wireless communication technology designed for low-power devices. It utilizes a client-server architecture where devices can act as:

  • Central (Client): it initiates and controls the communication.
  • Peripheral (Server): it provides the data or operations to be accessed.

Services and characteristics are the building blocks of BLE communication:

  • A service represents a collection of related functionalities or data.
  • A characteristic is a specific data value within a service.

To establish a connection, the peripheral device advertises its availability by broadcasting small messages known as advertisement packets. The central device, through a process called scanning, listens for these packets and can discover nearby peripherals.

Once the central device identifies a peripheral of interest, it can set up a connection and start interacting with the available services and characteristics.

In our project, the iOS app will act as the central, while the Arduino board will operate as the peripheral. The board will expose its data through two characteristics within two separate services:

  • Led Status Characteristic (in Led Service): It is a characteristic with the write property that enables the app to change the status of the LED by writing a value of 0 (Off) or 1 (On).
  • Temperature Characteristic (in Sensor Service): It is a characteristic with the read and notify properties that allows the app to retrieve the temperature value measured by the board and receive notifications of temperature updates.
Services and characteristics. Image by author.

Building the Arduino program

The Arduino program utilizes the ArduinoBLE and the Arduino_HTS221 library to enable BLE communication and temperature sensing functionality.

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

First, the sketch sets up the required BLE services and characteristics using the UUIDs, defining the properties for each characteristic. UUIDs (Universally Unique Identifiers) are unique identifiers used to identify the services and characteristics in the BLE communication protocol.

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);

Next, the program initializes the BLE module and sets the local name and advertised service. The local name, which is a user-defined name, is broadcasted by the Arduino and serves as a human-readable identifier for the peripheral. By setting the advertised service to the Led Service, the Arduino informs the iOS app or other central devices about the specific service it provides.

void setup() {
// ...

// BLE initialization
if (!BLE.begin()) {
while (1);
}

// set advertised local name and service UUID
BLE.setLocalName("iOSArduinoBoard");
BLE.setAdvertisedService(ledService);

The services and characteristics are added to the BLE stack, and a read request handler is set for the temperature characteristic.

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

The program then starts advertising and enters the main loop.

 // start advertising
BLE.advertise();
}

In the main loop, it listens for a central to connect, and once connected, it starts a loop to handle the communication.

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

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

Within this loop, the program periodically reads the temperature from the sensor and updates the temperature characteristic value. Additionally, it checks if the central device has written a new value to the LedStatus characteristic. If a new value is detected, it toggles the LED on or off accordingly.

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

Finally, a read event handler function is implemented to respond to read requests for the temperature characteristic. This function writes the current temperature value to the characteristic.

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

Designing the iOS App

The iOS app is designed with a user-friendly interface that consists of two main screens:

  • Scan screen: it acts as the initial screen when the app is launched, allowing users to start scanning for peripherals. It displays a list of discovered peripherals, and by tapping on the name of a peripheral, users can initiate a connection. Upon successful connection, the app switches to the Connect screen.
  • Connect screen: it provides an interface to interact with the connected Arduino. Users can modify the state of the LED, turning it on or off. Additionally, users can read the temperature in two modes: single reading (Read) or continuous monitoring (Notify) of variations.
App screens. Image by author.

The app is built upon an architecture inspired by the Clean Architecture with the MVVM (Model-View-ViewModel) design pattern, incorporating some adjustments to enhance simplicity and ease of understanding. This approach promotes component independence and testability by organizing the app into 3 distinct layers:

  • The Presentation Layer handles the user interface and interactions. It consists of the View, responsible for rendering the user interface, and the ViewModel, which manages the state of the View.
  • The Domain Layer represents the core business logic of the application. It encapsulates the use cases and interacts with the data. A use case represents a specific business action that can be performed by the application.
  • The Data Layer is responsible for data access and persistence. It includes the entities that provide an abstraction for data operations.
MVVM flow. Image by author.

The app will utilize CoreBluetooth to handle all Bluetooth-related operations. CoreBluetooth is a framework provided by Apple that enables seamless communication with Bluetooth devices on iOS platforms. It offers a comprehensive set of functions that simplify the implementation of Bluetooth functionality.

Leveraging CoreBluetooth, the app will be able to discover nearby Bluetooth peripherals, establish connections, and exchange data with devices.

Now, it’s time to get hands-on with iOS programming!
Before we dive in, there are a few essential tools we need to have: a Mac computer and Xcode, the official integrated development environment (IDE) for Apple platforms. Xcode offers a comprehensive suite of tools and resources that enable us to design, code, and debug our iOS applications.

Open Xcode, select “Create a new Xcode project” from the welcome screen and choose the App template from the iOS section. Provide a unique product name (iOSArduinoBLE), select the team identifier (your Apple ID name), and choose the language (Swift) and the location to save your project.

…and there you have it! Let’s get started.

XCode: create a new project. Image by author.

iOS App: Scanning and Connecting to the Arduino board

In the Scan screen, users are presented with a clean and intuitive UI that allows them to discover and connect to BLE devices.

Scan screen. Image by author.

The underlying architecture of this screen consists of:

  • ScanView: It is responsible for rendering the UI of the screen. It is designed using SwiftUI.
  • ScanViewModel: It handles events such as tapping the “Start Scan” button and selecting a device name to establish a connection. To execute the scanning and connection operations, the ScanViewModel relies on the CentralUseCase.
  • CentralUseCase: It encapsulates the necessary logic for interacting with the CoreBluetooth framework, utilizing the CBCentralManager object. Which is responsible for initiating scans, establishing connections, and managing peripherals within the framework.
Scan screen architecture. Image by author.

The Data layer consists of two entities: Peripheral and UUIDs. The Peripheral object represents the BLE device and holds details such as the device name, identifier, and other relevant properties. UUIDs is a collection that holds the list of services and characteristics available for use in the application.

Scanning

Once the “Start Scan” button is pressed, the ScanView captures the user interaction and forwards the request to the ScanViewModel for further handling.

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

The ScanViewModel, upon receiving the interaction, triggers the scanning functionality on the corresponding use case. The use case is instructed to discover only the devices advertising the LedService.

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

Finally, the use case interacts with the CoreBluetooth framework to initiate the scanning procedure.

//  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: [:])
}

When using the scanForPeripherals function in CoreBluetooth, you can handle the response through the CBCentralManagerDelegate methods. The delegate method for handling the discovered peripherals is didDiscover, which is called whenever a peripheral is discovered during the scanning process.

When a device is discovered, the CentralUseCase informs the ScanViewModel by calling the onPeripheralDiscovery closure (i.e., a self-contained block of code) and providing it with the details of the newly found peripheral.

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

The ScanViewModel updates its internal state and notifies the ScanView that a new device is ready to be displayed in the list of peripherals.

//  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
// ...
}
}

Connecting

Once a device in the list is pressed, the ScanView captures the user interaction and forwards the request to the ScanViewModel providing it the peripheral to connect to.

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

This action triggers the ScanViewModel to forward the connection operation to the respective use case.

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

Finally, the use case interacts with the CoreBluetooth framework by stopping the scanning process and initiating the connection procedure with the selected device.

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

If the connection is successful, the didConnect delegate method will be called. The CentralUseCase communicates this information to the ScanViewModel by invoking the onConnection closure.

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

The ScanViewModel updates its internal state and notifies the ScanView that the connection with the chosen device is established.

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

Accordingly, the ScanView proceeds to switch to the 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 App: Writing and Reading data

In the Connect screen, users have the ability to interact with the Arduino and exchange information related to the LED and temperature.

Connect screen. Image by author.

The architecture remains consistent with the previous screen and includes the following components:

  • ConnectView: It presents the UI elements for controlling the LED state, reading temperature information, and disconnecting from the device. It is implemented using SwiftUI.
  • ConnectViewModel: It captures user interactions such as tapping the on/off buttons for LED control, toggling the temperature notification enablement, pressing the read button for instant temperature readings, and triggering the disconnect button. It communicates with the PeripheralUseCase to handle these operations.
  • PeripheralUseCase: Similar to the CentralUseCase, it is responsible for the logic related to CoreBluetooth. It interacts with the CBPeripheral object, which is responsible for managing and exchanging data through characteristics and services.
Connect screen architecture. Image by author.

Before initiating data exchange with services and characteristics, it is necessary to perform the discovery phase. During this phase, the CoreBluetooth framework scans and retrieves information about the available services and characteristics offered by the connected peripheral.

Discovery

Upon creating the ConnectViewModel and its associated PeripheralUseCase, the discovery services procedure is initiated. For each discovered service, the corresponding characteristics are also discovered.

The responses from the CoreBluetooth framework are delivered to the respective delegate methods: didDiscoverServices and didDiscoverCharacteristicsFor. These methods provide the app with information about the services and characteristics found on the device.

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

Once the discovery process is completed, the ConnectViewModel is notified with onPeripheralReady, and the UI is prepared to handle operations on the discovered characteristics.

//  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?()
}
}

Controlling LED

When the user presses the on/off buttons, it triggers a write operation to the LedStatus characteristic with a numerical value (1 for “On” and 0 for “Off”). This action, in turn, controls the integrated LED on the board, either turning it on or off accordingly.

The entire process is managed using the usual flow:

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

Reading temperature

Temperature reading can be performed in two ways: a single-shot mode, where a read operation is triggered to obtain a one-time value, or a continuous mode, where a notify operation is used to receive real-time updates.

The flow of the read operation request is as follows:

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

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

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

Enabling/disabling notify operation flow follows the same pattern:

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

Both operations will generate a response on the same delegate of CoreBluetooth, specifically the didUpdateValueFor method. For the read operation, there will be a single response with the requested data. For the notify, responses will continue to be sent until the notification is disabled. Each response will trigger the onReadTemperature closure in the ConnectViewModel to update the UI state accordingly.

//  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
}
}

Disconnection

When the Disconnect button is pressed, the flow takes a slightly different direction. Since the disconnection operation belongs to the CBCentralManager object, it needs to be performed by the CentralUseCase. To enable this, when the user presses the button, the current screen is dismissed, and the user is taken back to the Scan screen.

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

When the ScanView appears, the first operation it performs is to check if a device is already connected, and if so, disconnect it. This operation is handled by the corresponding ScanViewModel, which then forwards the request to the CentralUseCase to perform the disconnection operation on the CoreBluetooth framework.

//  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!)
}

Conclusion

In this article, we have explored the journey from Arduino programming to iOS app development with a focus on building IoT projects with BLE connectivity.

We have learned how to create an iOS app using Xcode and CoreBluetooth framework. We studied the basics of BLE, the clean architecture principles, and implemented the MVVM design pattern to ensure a scalable and maintainable codebase.

This project can serve as a good starting point for your IoT projects. However, using CoreBluetooth directly may have some limitations such as complex handling of background operations and lack of higher-level functionalities (Mesh, Firmware update, etc.). To overcome these limitations, it is recommended to utilize third-party libraries that offer additional abstraction layers and support for advanced features.
Some popular options include:

  • RxBluetoothKit: A powerful ReactiveX-based BLE library.
  • Bluejay: A modern, user-friendly BLE library that emphasizes simplicity and ease of use.
  • LittleBlueTooth: A lightweight and straightforward BLE library.

Code

--

--

Leonardo Cavagnis

Passionate Embedded Software Engineer, IOT Enthusiast and Open source addicted. Proudly FW Dev @ Arduino