Skip to main content

Temperature and humidity measurement

What’s the temperature in your place at present? In this tutorial, you will get the readings using your board and a humiture sensor.

Learning goals

  • Roughly understand the I2C communication.
  • Learn about the data systems: hexadecimal, binary, and decimal.
  • Know how to use I2C to communicate with sensors.
  • Learn to use libraries to simplify your coding process.

🔸Background​

What is communication protocol?​

So far, you have learned simple input and output. It allows you to read or set the voltage on specified pins to communicate with some components. For example, LEDs need true (1) to be on and false (0) to be off, buttons read true if pressed and false if released.

However, there are much more advanced devices involving tons of data. It would be such an enormous project to control the signals manually. Luckily, you can simplify the process with the help of different communication protocols.

The communication protocols are many sets of widely accepted rules to decide the communications among devices. They specify the details to transmit and interpret data, including message format, signaling, error detection, etc. Each protocol has its own advantages and is used in a certain situation.

Imagine you meet with a person who speaks a different language from you. It's almost impossible to understand what that person is talking about. The communication protocols are quite similar. Only the devices that adopt the same protocol can understand each other and start communications.

Communication protocol

So let’s dive into some commonly used communication protocols in the microcontroller world. In this tutorial, you are going to learn about I2C.

What is I2C?​

Inter-integrated circuit, I2C (I two C or I squared C) for short, is a protocol suitable for short-distance communication. It allows multiple slave devices to communicate with one master device using merely two wires, which constitutes its great advantage compared to other protocols.

The two wires are called SCL and SDA.

  • SCL (serial clock) carries the clock signal generated by the master device. With a preset speed, the devices know the time for each transmission and can infer if it completes. Thus, it ensures synchronous data transmission between devices. There are several speed modes: standard (100kbps), fast (400kbps), fast plus (1mbps).
  • SDA (serial data) is the data line. Both master and slave devices send data over SDA line. The data is sent in bytes.
I2C

Master and slave device

During communications, a master device controls the communication process. It decides when the transmission starts and ends. Slave devices respond to the master device once it receives the signal. Usually, there would be only one master device but multiple slave devices.

Your SwiftIO Feather board will always serve as a master device, and the other I2C devices connected serve as slave devices.

Address

Why could only two wires support multiple devices without confusion? This is because each I2C device has its unique address. It's usually decided when designing the hardware and will be written on its datasheet. At the beginning of the communication, the master device will send the desired device address. Only the corresponding device would respond and prepare for the following process. Other devices will wait until they are called.

I2C address
note

Some devices may have several address options in case the devices you use happen to share the same address.

Data transmission

Each communication protocol has its own data transmission rules. These rules define how and when the data is transmitted one after another, how the voltage level changes during communication. If you want to know the details, you can read this article.

The SwiftIO library has already abstracted all stuff. Therefore, you can use I2C communication easily without knowing the details during transmission. In brief, you need the device address, followed by the data to be sent or the buffer to store the reading values. Later you will see an example to understand it better.

🔸New component​

Sensor​

First, let’s take a quick look at sensors.

Sensors are devices sensitive to natural properties or environmental changes, like temperature, sound, pressure, light, movement, etc. They can convert these physical inputs into electrical signals.

Depending on the output value, sensors can be divided into analog and digital sensors.

  • Analog sensors will give analog values, like potentiometers. There are also many common analog sensors, such as light sensors, temperature sensors, sound sensors, etc. This kind of sensor needs ADC to get readings but would be easier to use.

  • Digital sensors will produce digital readings of ones and zeros, like buttons (on or off). For many more advanced digital sensors, they actually read analog values in the beginning and then convert them to digital values using built-in ADC. So you can also find many digital temperature sensors. Nowadays, more and more digital sensors are used. They contain all stuff on a single chip and may support one or more communication protocols. They ensure more accuracy and provide more functionalities.

In short, almost all sensors represent the measured data in the form of voltage. Finally, the voltage values will be decoded to corresponding physical values, like temperature, distance, light intensity, etc.

Humiture sensor​

Humiture sensor allows you to measure both the temperature and humidity. SHT3x is one kind of digital temperature and humidity sensor and uses I2C to communicate with your board.

binary and hexadecimal table

The humidity measured by the sensor is relative humidity (RH for short). It describes the amount of water vapor in air in percentage compared to the maximum amount at the same temperature. 100% is the point the air achieves saturation. It is sensible to the temperature. If temperature increases, the air can hold more water vapor, and thus RH decreases.

The sensor contains components sensitive to temperature and humidity. For temperature measurement, a thermistor is always used. Its resistance changes with the temperature. As for humidity, the sensing element, a polymer in most cases, changes its conductivity due to water vapor content in the air. Then they cause voltage change in the circuit.

Finally, the sensor gives you raw values after calibration. You could use the formula provided on its datasheet to get the temperature in Celsius and relative humidity in percentage.

🔸New concept​

Number system​

The number system is the way used to represent numbers. The Decimal number system (base-10) is what you use in everyday life. The ten digits are from 0 to 9, and for larger values, you get more digits on units, tens, hundreds, thousands and so on. Besides, there are two more common types:

  • Binary is the base-2 number system. Only two digits (0 and 1) are used to represent all numbers. The data starts with the prefix 0b. For a binary number, each digit is a bit. A byte consists of 8 bits.

  • Hexadecimal is the base-16 number system. The data always starts with the prefix 0x. It used 16 symbols, digits 0 to 9, and letters A to F to represent all numbers. A to F is equivalent respectively to 10 to 15. Hexadecimal provides a more convenient way to represent those long binaries.

Take decimal number 48 for example, it's equivalent to 0b00110000 in binary and 0x30 in hexadecimal.

binary and hexadecimal table

🔸Circuit - humiture sensor module​

The humiture sensor is connected to I2C0 (pin SCL0 and SDA0).

Humiture module circuitHumiture module circuit diagram
note

The circuits above are simplified versions for your reference.

🔸Preparation​

Before start, you could find its datasheet by searching on the Internet. The datasheet lists all commands to configure the sensor and the ways to read values.

When dealing with all kinds of sensors, you will spend most of your time setting different commands for sensors. And if the sensor returns data, then read the values. In fact, the commands for sensors are usually determined and rarely change. So a library, aka driver, would be written to cover all common usages of sensors and provide you with simple APIs. In this way, you could directly use the functionalities in the library instead of referring to the long datasheet.

Well, take the library SHT3x for example, let’s see how I2C works.

Class

I2C - this class controls the I2C communication between your board and sensorss. The data is sent or read in bytes.

MethodExplanation
init(_:speed:)Initialize an I2C interface for the communication.
Parameter:
- id: the id of the I2C pin.
- speed: the clock speed for the I2C communication, standard (100kbps) by default.
write(_:count:to:)Send data to the specified slave device.
Parameter:
- data: an arrary of bytes sent to slave device.
- count: the count of bytes. By default, it's nil which means all bytes in data will be sent. Be sure the count should be smaller than the array count.
- to: the address of the slave device.
Return value:
A result indicating whether the communication is finished successfully or a type of error.
read(into:count:from:)Read data from the specified slave device.
Parameter:
- into: the buffer to store the coming data. It needs an UInt8 array which is passed as inout in order to be changed.
- count: number of bytes received from device.
- from: the address of the slave device.
Return value:
A result indicating whether the communication is finished successfully or a type of error.

SHT3x​

Click to view SHT3x source code.
import SwiftIO

final public class SHT3x {

private let i2c: I2C
private let address: UInt8

private var readBuffer = [UInt8](repeating: 0, count: 6)

// Initialize the I2C bus and reset the sensor.
public init(_ i2c: I2C, address: UInt8 = 0x44) {
self.i2c = i2c
self.address = address

reset()
}
// Reset the sensor.
public func reset() {
sleep(ms: 2)
try? writeValue(.softReset)
sleep(ms: 2)
}

// Get the temperature in Celcius.
public func readCelsius() -> Float {
try? getSensorData(into: &readBuffer)
let rawTemp = UInt16(readBuffer[0]) << 8 | UInt16(readBuffer[1])
return 175.0 * Float(rawTemp) / 65535.0 - 45.0
}

// Read the temperature in Fahrenheit.
public func readFahrenheit() -> Float {
try? getSensorData(into: &readBuffer)
let rawTemp = UInt16(readBuffer[0]) << 8 | UInt16(readBuffer[1])
return 315.0 * Float(rawTemp) / 65535.0 - 49.0
}

// Read the current relative humidity.
public func readHumidity() -> Float {
try? getSensorData(into: &readBuffer)
let rawHumi = UInt16(readBuffer[3]) << 8 | UInt16(readBuffer[4])
return 100.0 * Float(rawHumi) / 65535.0
}
}

extension SHT3x {
// Some common command with 16-bit data used to communicate with the sensor.
private enum Command: UInt16 {
case readStatus = 0xF32D
case clearStatus = 0x3041
case softReset = 0x30A2
case heaterEnable = 0x306D
case heaterDisable = 0x3066

case measureHigh = 0x2400
case measureMedium = 0x240B
case measureLow = 0x2416

case measureStretchHigh = 0x2C06
case measureStretchMedium = 0x2C0D
case measureStretchLow = 0x2C10
}

// Split the command into two bytes and send them to the sensor.
private func writeValue(_ command: Command) throws {
let value = command.rawValue
let result = i2c.write([UInt8(value >> 8), UInt8(value & 0xFF)], to: address)
if case .failure(let err) = result {
throw err
}
}

// Read
private func readValue(into buffer: inout [UInt8]) throws {
for i in 0..<buffer.count {
buffer[i] = 0
}
let result = i2c.read(into: &buffer, from: address)
if case .failure(let err) = result {
throw err
}
}

private func getSensorData(into buffer: inout [UInt8]) throws {
try writeValue(.measureMedium)
sleep(ms: 8)
try readValue(into: &buffer)
}
}

Let’s look at each part of the library in detail.

import SwiftIO

Import the SwiftIO library to use the I2C class to read or write data.

final public class SHT3x {
}

As you know, a class is like a template for all its instances who share the same properties and methods. You have used some built-in classes in SwiftIO library. Now you will define a new class. The statement needs the keyword class, followed by a class name SHT3x. This class is used to group all the common usages related to the sensor SHT3x.

Class is of reference type. If you create an instance sensor and assign it to another constant sht3x, in fact they point to the same one. If you change sht3x, sensor will also change, and vice versa. Like a file on your computer, even if you create multiple shortcuts on several locations, they all refer to the original file. That is why the class is usually used in your projects. Since the corresponding hardware is unique, any setting will cause hardware changes.

The keyword public decides the access control of your class. This class should be accessible to other projects in order to call all its methods, so it's public. private means it is accessible only within its type, such as the enum Command used only within this class.

What's more, a class with the keyword final cannot be inherited any longer.

private let i2c: I2C
private let address: UInt8

The variable or constant inside a class is called property. It must be initialized with a value. The two properties above don’t have an initial value and thus need an initializer.

private var readBuffer = [UInt8](repeating: 0, count: 6)

This statement creates a buffer to store the reading values during I2C communication. The buffer needs to be a UInt8 array.

  • The parameter repeating sets the default value for the array.
  • count is decided by the number of bytes the sensor will send. According to the datasheet, it is 6.

In this way, you get an array with 6 bytes whose values are all 0. This is a simple way to assign the array elements with the same default value. Later, these elements will be replaced with readings.

Let's talk about some data types. A UInt8 is an 8-bit unsigned integer from 0 to 255. An Int8 refers to an 8-bit signed integer from -128 to 127, whose first bit serves as a sign. Similarly, a UInt16 is a 16-bit unsigned integer from 0 to 65535.

public init(_ i2c: I2C, address: UInt8 = 0x44) {
self.i2c = i2c
self.address = address
reset()
}

The method or property inside a class also needs to be public to be accessible to other projects. The method init is used to create instances, so it is also public. An initializer is a special kind of function. It allows you to assign values to the properties and perform some setups when creating an instance.

This initializer takes two parameters.

  • The first one, of course, is the I2C pin that the sensor connects.
  • The address of this sensor is usually the same (0x44) and thus passed with a default value. In this way, you don’t need to set it when initializing a sensor instance. If the address happens to be different, you can pass the address value then.

self is used to refer to the instance itself, as the parameter names are the same as the property names. Then use dot syntax to access the property and assign the parameter value to the property.

The method inside a class can be directly used without dot syntax, as the method reset here. After initializing a new instance of SHT3x, the I2C communication gets ready, and the sensor is reset.

public func reset() {
sleep(ms: 2)
try? writeValue(.softReset)
sleep(ms: 2)
}

The method reset allows you to reset the sensor and reload its default settings. It is generated by the soft reset command. The ways of reset is decided by the sensor and usually written on its datasheet or sample code.

writeValue is a newly defined method to send command via I2C bus. You will look into it in detail later.

public func readCelsius() -> Float {
try? getSensorData(into: &readBuffer)
let rawTemp = UInt16(readBuffer[0]) << 8 | UInt16(readBuffer[1])
return 175.0 * Float(rawTemp) / 65535.0 - 45.0
}

This method needs no parameter and returns a Float.

A function (or method) with return values has the symbol ->, followed by the data type of the value. It should contain a statement with return keyword and the value. The return statement should be at the end since the statements after it will be skipped and never be executed.

The method readCelsius gives you the temperature in Celsius. It will

  1. send a measurement command to the sensor. Then read bytes from the sensor into the readBuffer using I2C communication.

  2. combine two bytes in the array into a UInt16 which represents a raw temperature value. According to the datasheet, the first two data are temperature readings. The fourth and fifth are humidity readings.

  3. convert the raw value into degree Celsius using the formula provided on the datasheet.

The instance methods readFahrenheit, readHumidity work similarly. And finally, one returns the temperature in Fahrenheit, and the other returns the relative humidity in percentage.

extension SHT3x {
}

This extension adds new functionalities to the corresponding class. One of its advantages is to organize your code and group different parts of code. For instance, here all public methods are in the class itself, and some private methods for internal use are written in the extension. It's a way to organize your code. You could, of course, write all code in the class without an extension.

private enum Command: UInt16 {
case readStatus = 0xF32D
...
}

Enumeration, usually called enum, is a type that contains a group of related values.

  • It starts with the keyword enum, followed by an enumeration name.
  • UInt16 tells the data type of case values.
  • Inside the curly brackets, you put a series of cases. Each case is attached to a default value, called raw value. To use a case, you use dot syntax, like Command.readStatus.

The enum Command contains some commands for the sensor according to the datasheet for easier reference. The sensor provides so many different commands. If you directly send 0xF32D to the sensor, you may be confused what it is for when you come back to this code in the future. So an enum is a great choice to list them all together. Each case tells the command usage, followed by a raw value which is the data sent to the sensor.

private func writeValue(_ command: Command) throws {
}

This method sends data over the I2C bus. It takes a parameter command which is of Command type. With the keyword throws after parameter, it can throw an error and propagates it to where it is called.

Let's take a look at the statements in it.

let value = command.rawValue

The constant value stores the raw value of the command. The enum case needs also dot syntax to get the raw value. The raw value is UInt16 so value is inferred to be UInt16 by Swift.

let result = i2c.write([UInt8(value >> 8), UInt8(value & 0xFF)], to: address)

write of the I2C class takes a byte array as the parameter. So you need to separate value into 2 bytes using bitwise operations.

Here are some bried explanations about the operations.

SymbolMeaningDiagram
<<left shift: all bits will move to the left, and 0 will be added to the empty bits on the right.Left shift
>>right shift: all bits will move to the right, and 0 will be added to the empty bits on the left.Right shift
&bitwise AND operator: it will calculate each corresponding bit of two data. Only if both two bits are 1, the result will be 1, or it will be 0.And operator
|bitwise OR operator: it will calculate each corresponding bit of two data. If either of the bits is 1, the result will be 1.Or operator
UInt8(value >> 8)
Get higher byte of UInt16

The value is 16-bit. In this statement, the bits will move to the right at first. So the original 8 bits on the right (marked with grey) are discarded. In the new data, the 8 bits on the right are replaced by the original 8 bits on the left. The empty 8 bits on the left (marked with red) are completed with 0. Then it's changed to UInt8 and discards the eight bits on the right. In this way, you get the first byte.

UInt8(value & 0xFF)
Get lower byte of UInt16

It does bitwise AND operation with 0x00FF (0b1111 1111). So the left 8 bits of result would be the same as those of the original data. The 8 bits on the right all become 0. After it is changed to UInt8, you get the second byte.

Back to the method write. The data array contains the two bytes, and they'll be both sent to the sensor. So the parameter count is not specified. address is the sensor address 0x44 specified above.

write returns a Result type data. It tell you whether the communication was completed or failed.

if case .failure(let err) = result {
throw err
}

If the transmission succeeds, that's great. But if it fails, you may want to know the error, so you only need to care about the failure case.

The error will not be handled in method writeValue, so you throw the error and make it a throwing method. The error will be handled when you call this method to send a command.

This mechanism is indeed so complicated. But the communication between devices may fail due to many unexpected factors, which will cause wrong data. With error handling, you can check if an error occurs and why it happens. Your projects thus become more stable and secure.

private func readValue(into buffer: inout [UInt8]) throws {
for i in 0..<buffer.count {
buffer[i] = 0
}
let result = i2c.read(into: &buffer, from: address)
if case .failure(let err) = result {
throw err
}
}

This method reads bytes from the sensor over an I2C bus. The parameter buffer is an inout parameter to allow it to be changed inside the method.

At first, you will change all data in the buffer to 0. So if the communication meets problems and doesn't give values, all bytes are still 0. You can immediately know there is an error.

The method read of I2C stores the bytes sent from sensor into a buffer.

  • The buffer is marked with & as an inout parameter.
  • The parameter count is not specifed as the buffer is 6 bytes long and meets the count to be read.
  • The third parameter is still the sensor address above.

The values needed have already been read into the buffer. So as for the result, the success case can be ignored, you still check only the failure case and throw it if there is an error.

BTW, the method writeValue and readValue are two basic methods used for I2C communication between your board and the sensor. As all desired values are passed in as parameters, they are independent of other methods or properties. Thus, you can easily reuse them in any of your libraries for other sensors.

private func getSensorData(into buffer: inout [UInt8]) throws {
try writeValue(.measureMedium)
sleep(ms: 8)
try readValue(into: &buffer)
}

This method sends measurement command to the sensor to get 6 bytes of data. writeValue and readValue all throws error, and the error won't be handled here. You will use the keyword try to propagate the method that calls it.

For example, the method readCelsius calls this method. It uses the try? to handle the error, which will return an optional. Also, the error is not handled to make this method much easier to use. Or you always need to unwrap the readings.

Since this is a general-purpose library and we don't know the specific situation where the library will be used, it's hard to decide when and how the error needs to be handled. Besides, it's not that frequent for this sensor to get wrong readings. If you want to make your project more secure, you could modify how the error will be handled.

Don't worry if you do not quite understand the code above. This library is provided, and you just need to import it to your project. Here, we want you to know how to use I2C communication and give you a general idea of how to write a driver for a sensor.

As you see, it's not easy to configure the sensor and get readings from it. But with existing libraries, coding becomes much easier. Now it's time to use the library SHT3x.

🔸Projects​

  1. Read temperature and humidity

1. Read temperature and humidity​

Let's use the humiture sensor to get the temperature and humidity. As you download the code, the sensor begins to work and you can see the value printed on the serial monitor. The value slightly changes. If you put your finger next to the sensor, the temperature may increase.

Example code

// Import SwiftIO to use I2C communication and MadBoard to use pin id. 
import SwiftIO
import MadBoard
// Import SHT3x to use its functionalities to read values.
import SHT3x

@main
public struct C01S05Humiture {
public static func main() {
// Initialize the I2C interface and use it to initialize the sensor.
let i2c = I2C(Id.I2C0)
let sht = SHT3x(i2c)

// Read the temperature and humidity and print their values out.
// Stop for 1s and repeat it.
while true {
let temp = sht.readCelsius()
let humidity = sht.readHumidity()
print("Temperature: \(temp)C")
print("Humidity: \(humidity)%")
sleep(ms: 1000)
}
}
}

Code analysis

import SHT3x

MadDrivers contains drivers for different devices and has been added to your project by default. SHT3x is one of them. So you need to import it to your project.

let i2c = I2C(Id.I2C0)
let sht = SHT3x(i2c)

At first, you initialize an I2C interface for the communication. Its default speed satisfies the needs of the sensor, so you can ignore it.

The class SHT3x needs an I2C pin to create an instance. So you pass the i2c instance created above. The device address is 0x44 by default, and you don't need to change it.

let temp = sht.readCelsius()
let humidity = sht.readHumidity()

Use the two instance method of SHT3x to read temperature and humidity values. As you can see, with a predefined driver, reading from sensors can be so easy.

print("Temperature: \(temp)C")
print("Humidity: \(humidity)%")

Print the value out.

  • The content is a string, so you add double quotation marks.
  • Then you need string interpolation to combine the constant and the expression. You add the constant name temp as a placeholder after a backslash and between a pair of parentheses. It will be replaced by its exact value later.

🔸More info​