Internet of Things

Control your home using watchOS, Firebase, and SwiftUI

Fabrice Beya
Firebase Developers
16 min readMay 6, 2021

--

This article is a continuation of our previous article on Getting started with ESP32 and Firebase, where we saw how we could send real time temperature and humidity readings from an ESP32 device from four separate rooms to our Firebase Realtime Database (RTDB for short). In this article we will build on that by demonstrating how to build a watchOS app that can fetch sensor data from RTDB and allow a user to monitor this in a nice and friendly UI. Below is what the final app looks like.

Prerequisites

  • Xcode 11+(we will be using SwiftUI)
  • A Firebase project with Anonymous Authentication enabled and a Realtime Database instance (see this article for detailed instructions to set this up)

We’re going to create a watchOS app that connects to our Firebase backend and fetches sensor data from a Realtime Database instance, and finally displays this data on our Apple Watch. To make the app more interesting we will allow the user to choose which sensors and room they would like to monitor.

To get started, open up Xcode and Create a new Xcode project.

Make sure you select Watch App, and select Next.

Give your project a suitable name, we will call ours Smart Hommie. Ensure that SwiftUI is selected as your Interface along with the Swift language. Select Next and choose a location for your project.

With our project created, we’re now ready to start coding.

Setting up watchOS with Firebase

Let’s build our application, by selecting the Resume button in the preview panel.

Next we need to add the Firebase library to our WatchKit Extension project. We will be using the new Swift Package management method to do this.

Go to File > Swift Packages > Add Package Dependency.

You should now see the Choose Package Repository menu, enter the following firebase GitHub repository url: https://github.com/firebase/firebase-ios-sdk.git, and select Next.

The package manager will search for the associated repository and return the available version in the Choose Package Options dialog. watchOS support was added from version 7.9.0 onwards, make sure you select any version above that, the current default of 7.11.1 looks good, we can select next to proceed.

Swift Package Manager will now search for the specific libraries in the repository, this could take a bit of time.

Once it’s done you should see the Add Package to Smart Hommie (your project name) dialog. We will be using the FirebaseAuth and FirebaseDatabase libraries.

Make sure you change the target to the Smart Hommie WatchKit Extension as opposed to the default Smart Hommie target as shown below.

If you don’t do this the Firebase libraries will be added to the wrong target. Select Finish.

You should now see the Firebase package amongst your other packages.

Next we need to import our Firebase configuration file from our backend project. To do this head over to your Firebase Console project. Select the iOS icon to add a new iOS project.

You will be taken to the registration dialog as show below. The first thing you need to enter is the iOS bundle ID, make you select the same Bundle Identifier as that used in your Xcode project. You can copy and paste this from the Signing & Capabilities settings in Xcode as shown below.

Lastly give your app a nickname (we will stick to Smart Hommie) and select Register app to continue.

You will then be given the option to download the Firebase config file. This file is important to configure your iOS app to have access to the Firebase server as it contains all the API keys which the Firebase SDK will use to gain access to Firebase project resources such as the Realtime Database. Download this file as indicated below and select Next.

We will keep the default options for the remaining steps, so in the Add Firebase SDK step select Next, this is not important as we already used the Swift Package Manager to add the Firebase libraries instead of using CocoaPods.

You can also ignore the Add Initialisation Code by selecting Next. Since we are using SwiftUI we will be initialising Firebase in a slightly different manner to the option suggested by the setup wizard.

Finally, select Continue to go to the Firebase Console.

Lastly we need to add our GoogleService-Info.plist config file to our Firebase target project, to do this, right click the target project Smart Hommie WatchKit Extension folder and select Add Files to “Smart Hommie” as shown below.

Browse to the config file you downloaded from Firebase and select the Copy items if needed option as shown below. We all set with Firebase configuration for our project.

Lastly head over to your app entry point (in my case Smart_HommieApp.swift). We need to initialise our Firebase SDK, we do this by first importing the Firebase library and then implementing a constructor which calls the FirebaseApp.configure() method as shown below.

import Firebase
import SwiftUI
@main
struct Smart_HommieApp: App {

init(){
FirebaseApp.configure()
}


var body: some Scene {
WindowGroup {
NavigationView {
ContentView()
.environmentObject(SensorsViewModel.shared)
}
}
}
}

Make sure you successfully build and run your project in the simulator. You should see the default Hello World text on the screen.

Model Definitions

We are going to use the MVVM design pattern as it is the recommended choice for SwiftUI. Let’s begin with defining our app models. Our project will use two models, one will be the Sensor model which will hold all our sensor properties and the second model will be the Location model which will hold all the properties of the different areas we place our sensors in a room. Create a new group by right clicking the target project and selecting Add Group. Name this new group Model, and add two Swift files for each model as shown below.

Next let’s define our Sensor model.

Since we want to display instance of this model in a SwiftUI List view, we need to conform the Sensor model to Identifiable.

Secondly we conform Sensor to Hashable which requires our model to implement a comparison static func shown at the end, this allows us to define which properties will determine whether two sensor are equal or not, you’ll note that we use the name and the deviceuid attributes to check for equality of two sensor models.

Next we define all our sensor properties which match the database structure from our previous tutorial. We then create three constructors, the first just creates an empty sensor model with default values along with a unique ID. The second constructor allows us to create instances of our model using specific values of our own, this will be used for creating mock data when prototyping our user interface. The last constructor takes in a dictionary, this will be the primary method used to create an instance of our sensor model by taking in a dictionary from firebase API snapshot as input. The last thing to note is that we do a conversion of our value property from double to string.

import SwiftUI
import Firebase
struct Sensor: Identifiable, Hashable {
var id: String = UUID().uuidString
var name: String = ""
var deviceuid: String = ""
var type: String = ""
var value: String = ""
var location: String = ""

init(){}

init(name: String, type: String, value: String, location: String){
self.name = name
self.type = type
self.value = value
self.location = location
}

init(dictionary: [String: Any]){
self.id = dictionary["id"] as? String ?? UUID().uuidString
self.deviceuid = dictionary["deviceuid"] as? String ?? ""
self.name = dictionary["name"] as? String ?? ""
self.type = dictionary["type"] as? String ?? ""
let value = dictionary["value"] as? Double ?? 0.0
self.value = String(format: "%.1f", value)
self.location = dictionary["location"] as? String ?? ""
}

static func == (lhs: Sensor, rhs: Sensor) -> Bool {
lhs.name + lhs.deviceuid == rhs.name + rhs.deviceuid
}
}

Next we define our Location model as shown below. This model is a lot simpler, once again we conform to Identifiable and Hashable. We also implement our comparison function which will just use the location name. We only have two properties for a location, being the name and the associated sensors in that location. In the future we can expand this, but for now this will suffice.

We have two constructors: one to create an empty location model with a unique ID, and the other one to create a location model based on specific name and sensor array values provided. Lastly we create some mock data to use when prototyping our user interface. You’ll note that we use the second Sensor model constructor as mentioned above to pass in some unique values.

import SwiftUI
import Firebase
struct Location: Identifiable, Hashable {
var id: String = UUID().uuidString
var name: String = ""
var sensors: [Sensor] = []

init(){}

init(name :String, sensors : [Sensor]){
self.name = name
self.sensors = sensors
}

static func == (lhs: Location, rhs: Location) -> Bool {
lhs.name == rhs.name
}
}
let MockLocations = [
Location(name: "Living Room",
sensors: [Sensor(name: "DHT11", type: "Temperature", value: "24.5", location: "Living Room"),
Sensor(name: "DHT11", type: "Humidity", value: "70", location: "Living Room")]),
Location(name: "Main Bedroom",
sensors: [Sensor(name: "DHT11", type: "Temperature", value: "24.5", location: "Living Room"),
Sensor(name: "DHT11", type: "Humidity", value: "70", location: "Living Room")]),
Location(name: "Bathroom",
sensors: [Sensor(name: "DHT11", type: "Temperature", value: "24.5", location: "Living Room"),
Sensor(name: "DHT11", type: "Humidity", value: "70", location: "Living Room")]),
Location(name: "Office Space",
sensors: [Sensor(name: "DHT11", type: "Temperature", value: "24.5", location: "Living Room"),
Sensor(name: "DHT11", type: "Humidity", value: "70", location: "Living Room")])]

ViewModel Definition

Given the simplicity of this project we will define one main view model which we will make accessible to all our views, and which will host most of our business logic. As the project grows we will reconsider this approach and refactor our code accordingly.

Create a new group called ViewModel and add a new wift file called SensorViewModel.swift. We define a class which inherits from ObservableObject, allowing our views to automatically update whenever we update our view model properties. In the case of SwiftUI we need to define published variables, when these published variables are updated in our view model, they trigger an automatic refresh of the associated views that use it.

The first thing we do is define a static shared variable which allows us to create a shared instance of our view model which can be used across our entire application.

We then create a set of published variables which will all be used to update our views. The locations variable will hold an array of locations, this will be the main variable which contains all our sensor data organised per location, we also have a commented out version of the locations variable initialised with our MockLocations data, which we can use when we need to test the application without calling Firebase’s APIs.

Next we have two variables selectedLocation and selectedSensor, these are used to keep track of which options are currently selected by the user and in the event of a real-time update we can display any real-time changes to their current selection by updating these two variables.

We define an isBusy variable to keep track of when we are fetching data from the server, this is used to display a progress view on our screen letting the user know to wait.

Lastly we have two boolean variables to control which screen we display to the users, the showSensorView and showLocationView, they are used to control the navigation links needed to show our locations and sensor detail screens.

Our view model only has one main function which is fetchSensor(), we run this in our constructor. The fetchSensors function uses the Firebase Realtime Database API to fetch all our sensor data from Firebase and load it in our locations variable mentioned above. We use the observe API to check for any changes made to our database. Upon initialisation, it will load the current state of the database and will listen to any changes made to our database. This allows us to have a real-time feed of any changes that our ESP32 sensors are publishing to RTDB at any given moment, which from our previous tutorial is 10 seconds, so every 10 seconds the logic inclosed by DB_REF.observe() will be executed with updated values from all our devices.

Inside this function we firstly check that we have new data, otherwise we exit the function using a return. If we have new data we firstly use the locationsArrayDictionary to cast our data into a dictionary array of [key:“Location names” : “Location data”], we then loop through this array to build an instance of our location model for all locations contained in the locationsDictionary array. Given that each location data will contain an array of sensors we apply a similar approach to cast our sensor data into sensorArrayDictionary which we loop through and create instances of our sensor model. We then use a local variable called locs to append each location model instance, this is then passed onto our globally published locations variable.

We make use of an extension function called uniqued(), this allows us to remove any duplicate elements from all our arrays. This takes advantage of our conformity to Hashable and uses our custom comparison function to determine where we have a unique item in our array or not, in the event that a similar item is found it will be deleted. We use this both for our sensor array and locations array.

Lastly we also check if there is any changes to our currently selected sensors and selected locations and we update then accordingly using the selectedSensor and selectedLocations variables.

Throughout the operation isBusy is used to display a progress view to the user, and once we have completed fetching all our data we use set isBusy to false to remove the progress view.

import SwiftUI
import Firebase
class SensorsViewModel: ObservableObject {
static let shared = SensorsViewModel()
@Published var locations: [Location] = []

// @Published var location = MockLocations

@Published var selectedLocation: Location = Location()
@Published var selectedSensor: Sensor = Sensor()

@Published var isBusy = false

@Published var showSensorView = false
@Published var showLocationView = false

init(){
fetchSensors()
}

func fetchSensors(){
isBusy = true

DB_REF.observe(DataEventType.value, with: { snapshot in
if !snapshot.exists() {
self.isBusy = false
return
}
guard let locationsArrayDictionary = snapshot.value as? [String: AnyObject] else { return }

var locs: [Location] = []
for (key, value) in locationsArrayDictionary {

let sensorArrayDictionary = value as? [String: Any] ?? [:]

var sensors: [Sensor] = []

for (_, value) in sensorArrayDictionary {
let sensorDictionary = value as? [String: Any] ?? [:]

let sensorObject = Sensor(dictionary: sensorDictionary)
sensors.append(sensorObject)

if sensorObject.name + sensorObject.deviceuid == self.selectedSensor.name + self.selectedSensor.deviceuid {
self.selectedSensor = sensorObject
}
}

if self.selectedLocation.name == key {
self.selectedLocation.sensors = sensors.uniqued()


}

locs.append(Location(name: key, sensors: sensors.uniqued()))

}
self.locations = locs.uniqued()

self.isBusy = false

})
}
}

View definitions

Lastly we will work on our user interface. Let’s start with our main app entry view Smart_HommieApp.swift. We’re going to add an enviromentObject modifier to our ContentView(), this will allow us to provide a shared instance of our SensorViewModel across our application.

import Firebase
import SwiftUI
@main
struct Smart_HommieApp: App {

init(){
FirebaseApp.configure()
}

var body: some Scene {
WindowGroup {
NavigationView {
ContentView()
.environmentObject(SensorsViewModel.shared)
}
}
}
}

Let’s get started with our first view being the ContentView(). By default we have a Text view showing “Hello World”. We’re going to replace that with the code below. First we declare a global variable which will inherit our shared instance of the SensorViewModel environment object we provided from our parent view. Then we use the isBusy property to determine when to show a ProgressView() or our home page.

In our home page we use an Image view to display an orange house icon, taking advantage of the new built in SF Fonts support. We then include a welcome message using a Text view. We also apply modifiers to both these views to give it some padding, centering the text, adjusting the font size and font weight.

Lastly we display a list of all locations using a ForEach loop which uses our locations property from our sensorViewModel to display all our sensor locations. Inside the loop we just use a Button view to display the location name, we add some modifiers to give it the curved button look. When our button is pressed we set the selectedLocation property of our viewModel along with he showLocationView property which will be used by a navigation link.

We wrap all our views inside a ScrollView, allowing us to scroll up and down the list of locations. Lastly we add a navigation link which navigates to the LocationView() when we toggle the showLocationView property, this is triggered when we select a location inside our ForEach loop.

import SwiftUIstruct ContentView: View {
@EnvironmentObject var sensorsViewModel: SensorsViewModel

var body: some View {

if sensorsViewModel.isBusy {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Color.blue))
.scaleEffect(x: 2, y: 2, anchor: .center)
} else {
ScrollView{
NavigationLink(
destination: LocationView().environmentObject(sensorsViewModel),
isActive: self.$sensorsViewModel.showLocationView,
label: {})
.buttonStyle(PlainButtonStyle())

VStack{
Image(systemName: "house.fill")
.font(.title)
.foregroundColor(.orange)

Text("Welcome to Smart Hommie!")
.font(.headline)
.multilineTextAlignment(.center)
.padding()
}

Text("Your Locations:")
.font(.subheadline)
.padding()

ForEach(sensorsViewModel.locations) { location in
Button(action: {
self.sensorsViewModel.selectedLocation = location
self.sensorsViewModel.showLocationView = true

}, label: {
Text(location.name)
.foregroundColor(.white)
.padding()
.frame(width: WKInterfaceDevice.current().screenBounds.width*0.95)
.background(Color.blue)
.clipShape(Capsule())

})
.buttonStyle(PlainButtonStyle())

}
.padding(.horizontal)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(SensorsViewModel.shared)
}
}

Now let us create a new group called View and create two new SwiftUI files, SensorView and LocationView.

You can compile and run this in the simulator and you should see the following result:

Awesome!

Let’s now update our LocationView to display a list of sensors inside the chosen location from the previous screen. Once again we import our shared instance of the sensorViewModel, and we use the selectedLocation property to display our selected sensor data in this screen. We simply display the location name as a title with a headline modifier, just like in the home page we use a ForEach loop to display all the sensors in the location using the sensors property of the location model. Once again we use a text button using the sensor name as the button title, we add an interesting modifier which checks the type of sensor and adapts the color of the button accordingly. Lastly we use another navigation link to navigate to the SensorView when the user selects a sensor.

import SwiftUIstruct LocationView: View {
@EnvironmentObject var sensorsViewModel: SensorsViewModel

var body: some View {
ScrollView{
NavigationLink(
destination: SensorView().environmentObject(sensorsViewModel),
isActive: self.$sensorsViewModel.showSensorView,
label: {})
.buttonStyle(PlainButtonStyle())

VStack{
Text(self.sensorsViewModel.selectedLocation.name)
.font(.headline)

VStack(spacing: 6){
ForEach(sensorsViewModel.selectedLocation.sensors) { sensor in
Button(action: {
self.sensorsViewModel.selectedSensor = sensor
self.sensorsViewModel.showSensorView = true
}, label: {
Text(sensor.name)
.font(.caption)
.foregroundColor(.white)
.padding()
.frame(width: WKInterfaceDevice.current().screenBounds.width*0.95)
.background(sensor.type == "Temperature" ? Color.red : sensor.type == "Humidity" ? Color.blue : Color.gray)
.clipShape(Capsule())

})
.buttonStyle(PlainButtonStyle())
}
}
.padding()
}
}
}
}
struct LocationView_Previews: PreviewProvider {
static var previews: some View {
LocationView().environmentObject(SensorsViewModel.shared)
}
}

You can compile and run this, from the home page select any of the sensors, and you should be taken to LocationView page we just created.

Lastly we look at our SensorView page. Once again we declare our sensorViewModel from which we use our selectedSensor property to populate our sensorView page. Just like the locationView we the sensor type property to display different icons next to the sensor name, we also use this approach for the icon colors. Then we use a standard set of Text views to display the sensor value where once again we use the sensor type to display different units for our sensor values. And lastly we add the sensor location name at the bottom of this screen.

import SwiftUIstruct SensorView: View {
@EnvironmentObject var sensorsViewModel: SensorsViewModel

var body: some View {
VStack{
HStack{
Image(systemName: self.sensorsViewModel.selectedSensor.type == "Temperature" ? "thermometer" : self.sensorsViewModel.selectedSensor.type == "Humidity" ? "drop.fill" : "")
.font(.headline)
.foregroundColor(self.sensorsViewModel.selectedSensor.type == "Temperature" ? .red : self.sensorsViewModel.selectedSensor.type == "Humidity" ? .blue : .gray)

Text(self.sensorsViewModel.selectedSensor.location)
.font(.headline)
}

Spacer()

if self.sensorsViewModel.selectedSensor.type == "Temperature" {
Text(self.sensorsViewModel.selectedSensor.value + " °C" )
.font(.system(size: 58, weight: .light))
}
else if self.sensorsViewModel.selectedSensor.type == "Humidity" {
Text(self.sensorsViewModel.selectedSensor.value + " %" )
.font(.system(size: 58, weight: .bold))
}

Spacer()

Text(self.sensorsViewModel.selectedSensor.name)
.font(.subheadline)

}
}
}
struct SensorView_Previews: PreviewProvider {
static var previews: some View {
SensorView().environmentObject(SensorsViewModel.shared)
}
}

You can compile and run this on the simulator and you should see the following:

This concludes our application. In order to test the real-time updates, go to a sensor page and update the value on Firebase Realtime Database, and check that the value updates automatically on the watch. If you followed the first tutorial, you should see the sensor values updating every ten seconds.

The final code is available on GitHub, and should hopefully come in handy one day when you decide to build a similar project.

Conclusion

We started in our first tutorial to build an ESP32 embedded application that uploads temperature and humidity sensor data to a Firebase Realtime Database, in this tutorial we used that data to build a watchOS app which can be used to view the sensor data in realtime. We can further add more sensors like an energy meter or water meter, and using our current framework, we can easily update our applications to also monitor those metrics in our house using an Apple Watch.

--

--