This article aims to enable users of any skill level to read out live measurements from any commonly available Sensirion sensor, using our open-source autodetection library. It'll handle all the driver intricacies for you, and you'll see what your sensor measures!
We will go over the hardware you'll need, how to set it up properly, and how to configure the software to, in the end, read out the measurements from your Sensirion sensor. Prior knowledge with embedded computing or advanced programming skills are not required.
What you'll need- A PC/Laptop
- A microcontroller, such as an ESP32 DevKit. In this article, we'll use a ESP32 DevKitC from Espressif Systems, a rather common and cheap example, retailing for about $9. Make sure your board supports I2C Communications, though this is a standard feature. The board must also support C++ containers, which is not the case for eg. Arduino Uno R3/R4.
- One or multiple Sensirion sensors, such as SCD30 or SCD4X CO2 sensors, SHT4X humidity & temperature sensors, SEN5X, SGP4X environmental sensors, and more
- Wires to connect the sensor to the board's pins.
- A cable to connect your microcontroller to your PC
The setup of your hardware greatly depends upon, well, your hardware: depending on the microcontroller you're using and what type of sensor you have, you'll have to connect them accordingly. The procedure I'll outlign is generalizable to any hardware commonly available, so do not feel disheartened if you have some other microcontroller, you'll be able to reproduce the results by following the procedure below.
Finally, before proceeding any further I'd recommend disconnecting your microcontroller for any power supply. You're holding multiple jewels of technology in your hands, it'd be a shame for any damage to occur due to a short-circuit or an electric shock.
The Microcontroller
Let us begin with the board. In my case, I have a ESP32-DevKitC_v4, as it says on the back. Conveniently, as is often the case on hobbyist boards, the manufacturer has taken the care of printing on the device what the different pins correspond to, and we do not need to look up some blueprint. We'll note a few, particular pins we're going to use:
- 3V3 and 5V: 3.3V, resp. 5V power supply pins
- GND: Electrical ground (there's more than one, any will do)
- The numbered pins can be remapped to (almost) any of the chips features, eg. to control an electric motor through pulse-width modulation, or, as is our case, to communicate with the sensor via the I2C protocol. This may not be a feature on your board, so make sure to look up in your user manual which pins correspond to the I2C bus on your hardware. In our case we'll make the arbitrary choice of pins 21 and 22 for our bus' SDA and SCL lines, as they are the default pins for this purpose.
The Sensor(s)
For easy connectivity we've chosen to use sensors already soldered to a PCB with a STEMMA QT form factor. We'll cover an additional kind of sensor in this tutorial to help you connect your sensor in the general case.
The first case is the easiest: find yourself a QWIIC breakout cable and connect the dots: Black (GND) goes to GND, Red (VDD) goes to 3V3, blue (SDA) goes to pin 21 and finally Yellow (SCL) goes to pin 22. Breakout cables are often identifiable by their colours, so keep this pattern in mind.
The second is a bit trickier, as there are more cables to connect. Fortunately, Sensirion sensors ship with a thorough technical documentation, where you'll find this information in the pinout diagram:
To connect this sensor, find yourself a JST GH 6 pin breakout wire (you likely got one with your sensor) and connect the dots: Black (GND) goes to GND, Red (VDD) goes to 5V, Green (SDA) goes to 21, Yellow (SCL) goes to 22. Furthermore, we must connect Blue (SEL) to GND to signal to the sensor that we wish to communicate with it via I2C. If your I2C connection to the sensor ends up not being stable enough, you might try to connect both GND and SEL to the same GND pin on the board, using a breadboard or a Y-shaped jumper cable. The last connection, Purple (NC), is left floating.
If you follow this two-step procedure (1. look up the pinout diagram for your sensor, 2. connect VDD, GND, SDA, SCL and optionally SEL) outlined above you'll successfully connect all sensors supported by the library.
A final remark regarding the power supply: be sure to always check whether your sensor requires 5V or 3.3V. You're unlikely to fry your Sensirion sensor if you were mistakenly to connect a 3V sensor to a 5V power supply, but this may not always be the case.
Daisy-Chaining
The nice thing about an I2C bus is that it is a bus and not an end-to-end connection. In I2C, the Master, refers to the Slaves, as they're called, by their addresses and sets the tempo of the communication via the SCL line. Data is sent either by the master (in our case the microcontroller sends commands) or by one of the slaves (a sensor sending its measurements) in a highly choreographed manner, via the SDA line. While all sensors on the bus are listening in, sensors not mentioned by their address ignore what is being transmitted, and the master gets a reply only from the sensor he's addressed, regardless of who else is connected to the bus. This is great news for us as it allows us to daisy-chain sensors (eg. using the QWIIC connector) and obtain measurement data from each of them by polling them sequentially. Only limitations are bus length (eventually the signal gets too noisy, avoid buses longer than 30cm); and that we cannot have two sensors with the same address on the bus at any given time, as they'd be chatting over each other. The I2C address of a sensor is set at the factory and identical for sensors of a same family, which means you cannot connect eg. two SCD4X sensors on the same bus simultaneously.
Once you are satisfied by your work you may connect your board to your computer, in our case with a USB-A to Micro-USB cable that shipped with the board.
The softwareThe last piece of our puzzle is the software we'll use to upload a program to the board, and monitor its output. There are two main routes one may take here: Arduino IDE and PlatformIO. Arduino IDE is perhaps the better choice for beginners, as it offers a friendly user interface and is generally more forgiving, however experienced programmers will quickly feel constrained by it and will lack advanced features one expects from an IDE, such as dependencies control. For the simple example we are working towards here, any of the two should be fine, so feel free to pick whichever one suits your style better after reading the instructions below. Both options are available free of charge.
Setting up the IDEArduino IDE
Install the software from the official website and read this short tutorial to get an introduction to the IDE.
Next, select your board and port in the Board Manager by following these instructions.
Then, we'll need to install all the libraries required to run Autodetection. In the library manager, search for the Sensirion UPT I2C Auto Detection library. Be sure to select to install all dependencies, lest you'll have to add the following manually:
- Sensirion Arduino Core
- Sensirion UPT Core
- Sensirion I2C SCD4x
- Sensirion I2C SFA3x
- Sensirion I2C SVM4x
- Sensirion I2C SHT4x
- Sensirion I2C SEN5x
- Sensirion I2C SCD30
- Sensirion I2C SGP41
- Sensirion I2C STC3x
PlatformIO
The most straight-forward way to use PlatformIO is as an extension to Microsoft's Visual Studio Code, you'll find it easily among the extensions available for it. I'll refer to the official installation instructions here.
Once you have this software installed you'll need to create a folder to contain your project, and then open this folder with VSCode (or, if you have VSCode already open, go to File > Open Folder). You can then create your PlatformIO project either via the UI (click the little Home icon at the bottom left corner of your VSCode window) or by typing pio project init
into the command line (which you can open with ctrl+J
). You will see a bunch of directories and files appear in your folder, most of which we'll ignore. In fact, you may delete all of them, save for the platformio.ini
file and the src/
folder. More detailed instructions are available here.
The platformio.ini
file serves as PlatformIO's main configuration tool. This is where you'll specify the model of the board, the serial monitor baud rate, as well as the libraries your project depends on, which in our case is the Sensirion Sensor Autodetection library as well as all the drivers for the sensors supported by it. The file lives in the root folder of your PlatformIO project, so navigate to it if you've created the pio project via the GUI, or simply create a text file named platformio.ini
. Next, overwrite the default contents and/or emplace the following:
[platformio]
default_envs = SensorAutodetection
[common]
lib_deps =
Wire ; To access your hardware's I2C Features
Sensirion UPT I2C Auto Detection
[env:SensorAutodetection]
lib_deps =
${common.lib_deps}
platform = espressif32
framework = arduino
monitor_speed = 115200 ; Standard communication speed, we'll have to specify the same value in our program
board = esp32dev ; Find your parameter here: https://docs.platformio.org/en/latest/boards/index.html
PlatformIO will automatically install all these libraries for you when you'll build the project.
We are now ready to start coding! We will write a simple program in C/C++ (the standard for embedded programming) in the Arduino Framework (a template for our embedded program, if you will) that will automatically detect the sensors you connect to your board, and show to console the measurements they produce. Let's go!
CodingStep zero is making a file to host the code of our script.
- Arduino IDE:
File > New Sketch
orctrl+N
. - PlatformIO: Create the file
main.ino
in thesrc/
folder in the directory of your pio project. Can be done either by right-clicking into the directory structure in the Sidebar (ctrl+B
if not shown) or with the following command:$ touch src/main.ino
(ctrl+J
opens the console).
Structure of our sketch
Embedded programs typically feature two main components in their code: a setup and a loop, which, as their name suggests, serve to set up everything and then loop forever. In our case, we'll strive to set up the board's I2C bus to the two pins we've chosen as well as create the SensorManager, which will handle communications to and from the sensors we've connected. So, let us implement these two functions in a code file:
void setup() {
// put your setup code here, to run once:
}
void loop() {
// put your main code here, to run repeatedly:
}
If you've ever programmed before you'll likely recognize that these functions take no arguments (the parentheses are empty) and return nothing (as indicated by the void
return type). Indeed, since they are the main components of an Arduino sketch, the compiler (the software that will translate what we write into a lot of ones and zeroes that form our program) expects to find these exact functions, and it is mandatory that we conform to it. This is also the reason we do not have an int main(){}
as would be customary in regular programs written in C/C++.
Headers and global variables
Next, we'll include a bunch of header files which allow us to use the features we'll require for our sketch. Namely:
Arduino.h
: (Redundant on Arduino IDE) Definitions of Arduino-specific functions and objectsWire.h
: to access the board's I2C featuresSensirion_Sensor_Auto_Detection.h
: the autodetection library
This is achieved by adding the following lines at the top of your code:
#include "Arduino.h"
#include "Wire.h"
#include "Sensirion_Sensor_Auto_Detection.h"
Next, we'll declare the objects we need throughout our program's lifetime in global scope, ensuring both setup()
and loop()
can access them:
I2CAutoDetector i2CAutoDetector(Wire);
SensorManager sensorManager(i2CAutoDetector);
int maxNumberOfSensors;
const MeasurementList **dataPointers;
A few remarks are in order on the last two declarations, maxNumberOfSensors
and dataPointers
, to explain what they are. At this point in our program, we have no indication à priori on how many sensors could be connected to the I2C bus, or how many signals they collectively yield. Yet we must allocate some memory into which we later will be able to write the measurements as they come in! The solution to this conundrum implemented by the library is a hashmap of pointers to Measurement containers: the autodetection library knows how many sensors can theoretically be connected simultaneously (we'll ask it to assign a value to maxNumberOfSensors
), so we'll create a list of this many pointers to MeasurementList
objects. Since we do not yet know the size of this array, we declare a pointer to pointers to MeasurementLists
because that is what arrays in C really are, and allocate the memory once we know. With this setup, we'll later be in the position of asking the SensorManager
to fill in pointers to MeasurementLists
it'll have filled in with sensor data for the slots in the hashmap that correspond to the connected sensors.
You can think of the dataPointers
as a collection of flags on a "reverse PO box", with the flag being set for mailboxes (MeasurementList
s) that contain mail (or sensor data, Measurement
s, in our case), and of the object we've declared as the address of the room we'll put our PO box into, once we know how many mailboxes it must accommodate. Later our program will peek into the flagged mailboxes to retrieve the measurements put there by the SensorManager
.
setup()
With that in mind, we can tell our board how to set itself up. The setup tasks are as follows:
1. Define the communication speed via the serial bus (Must match the monitor_speed
in platformio.ini
or the setting in Arduino IDE's serial monitor);
2. Set up the I2C bus using the correct pins;
3. Build the "reverse PO Box flags" at the correct size. We'll consider null
pointers to signal that no measurements are available.
which, translated to code look as follows:
void setup(){
// 1.1 Initialize serial port at 115200 baud
Serial.begin(115200);
// 1.2 Initialize the I2C bus on pins 21 (SDA) and 22 (SCL)
int sda_pin = 21;
int scl_pin = 22;
Wire.begin(sda_pin, scl_pin);
// 1.3 Build the "reverse PO Box" we'll use to retrieve sensor data
maxNumSensors = sensorManager.getMaxNumberOfSensors();
dataPointers = new const MeasurementList* [maxNumSensors] { nullptr };
}
loop()
We are now ready to enter an infinite loop, in which we'll ask the SensorManager
to continuously provide us with Measurements
, and then print them to the console. This will be the tasks we'll perform at every iteration of the loop:
1. Ask SensorManager to poll all sensors for their latest data (thereby filling the PO Box)
2. Go through all mailboxes: if not empty, show the contents.
3. Clear all mailboxes of their contents
4. Wait for a bit (as to not flood the console with measurements, most of which would be redundant as the refresh rate of the Sensirion sensor is typically measured in seconds)
Before we dive into the code, we must talk a bit about what we'll receive: embedded within MeasurementList
we'll find Measurement
s, which encode both the measured value as well as some metadata. Namely, a Measurement
contains the following fields:
// Do not add this to your code, it's just for reference
struct Measurement {
DataPoint dataPoint;
SignalType signalType;
MetaData metaData;
};
where
DataPoint
is a{time, value}
pair,SignalType
describes the nature of the signal (eg. a CO2 concentration in ppm),MetaData
contains information about the sensor authoring the device, to the best of the autodetector's knowledge.
With this in mind, here's how one could implement the algorithm outlined above:
void loop() {
// 2.1 Task SensorManager to fetch sensor data
sensorManager.refreshAndGetSensorReadings(dataPointers);
// 2.2 Peek into the non-empty mailboxes and show their contents
for (int i = 0; i < maxNumSensors; i++) {
if (dataPointers[i] != nullptr) {
const MeasurementList measurementList = *dataPointers[i];
for (int m = 0; m < measurementList.getLength(); m++) {
const Measurement measurement = measurementList.getMeasurement(m);
if (m == 0) {
Serial.print("Showing measurements for sensor ");
Serial.println(sensorLabel(measurement.metaData.deviceType.sensorType));
}
Serial.print("Measured ");
Serial.print(quantityOf(measurement.signalType));
Serial.print(": ");
Serial.print(measurement.dataPoint.value);
Serial.print(" ");
Serial.println(unitOf(measurement.signalType));
}
Serial.println();
}
}
// 2.3 Clear all mailboxes
for (int i = 0; i < maxNumSensors; i++) {
dataPointers[i] = nullptr;
}
// 2.4 Wait for 1000 milliseconds
delay(1000);
};
Note that we've made use of the functions sensorLabel()
, quantityOf()
and unitOf()
, three helpful methods provided by the UPT Core library translating some of the fields in Measurement
to human-readable strings. The library also provides some functions to directly print whole Measurement
s to console, be sure to check out the example scripts in either the UPT Core or Sensor Autodetection libraries if you want a more comprehensive understanding of the fields making up a Measurement
.
A few more notes:
Serial
is an Arduino-specific communication interface.Serial.print()
and its sister methodsSerial.println()
andSerial.printf()
print data to the serial monitor, which we'll see on our computer.- Recall how our data is provided as a list of pointers to
MeasurementList
s? We used this fact twice: first by detecting the presence of sensor data through non-null pointers, and a second time by performing pointer de-referencing with the star operator*
. This operator tells the program to copy the values written at the memory location designated by the pointer to themeasurementList
variable in the first loop.
We are now ready to build our sketch, upload it to our board and monitor the sensor readings through the serial output.
- Arduino IDE: Click the Upload button (top-left of the window, labelled with an → arrow) and then open the serial monitor (Q-looking button at the top-right of the window). Be sure to select
115200 baud
in the drop-down to the right of the serial monitor. - PlatformIO: Run the following command in the console (which you can open by typing
ctrl+J
):pio run -t upload && pio device monitor.
The board's outputs are now shown in the console.
We should see measurements streaming in:
Showing measurements for sensor UNDEFINED
Measured UNDEFINED: 0.00 UNDEFINED
Measured UNDEFINED: 0.00 UNDEFINED
Measured UNDEFINED: 0.00 UNDEFINED
But why are all the fields UNDEFINED
? Well, because in this case I connected a SCD41, which needs a few seconds to get ready before it can produce measurements. During this time span, the Measurements are not defined. Just a few seconds later, we can see values streaming in:
Showing measurements for sensor SCD4X
Measured CO2: 1603.00 ppm
Measured T: 27.41 degC
Measured RH: 33.10 %
In case you've got multiple sensors connected, you'll see their outputs neatly organized by sensor:
Showing measurements for sensor SCD4X
Measured CO2: 1402.00 ppm
Measured T: 23.40 degC
Measured RH: 45.15 %
Showing measurements for sensor SHT4X
Measured T: 25.06 degC
Measured RH: 44.39 %
Showing measurements for sensor SGP4X
Measured RAW VOC: 63485.00 n/a
Measured RAW NOX: 37115.00 n/a
Good to know
Some sensors need to regularly perform measurements to stay well-conditioned, because a measurement causes a heater to turn on ensuring the sensor stays at it's rated operation temperature. If you set the delay
in the loop to a too large value (say, greater than a few seconds) you might get the following error message:
AutoDetect refresh rate too low: sensor SGP4X conditioning deprecated. Decrease update interval.
The QWIIC connectors make it possible to rapidly plug in different sensors, but this can sometimes lead to power surges on the delicate circuitry of your microcontroller, which can lead to a crash. It could also cause your sensor to not show any measurements. In that case, try plugging in again, or cycle the power by pressing your board's RESET
button.
Hopefully this tutorial helped you get some measurements from your Sensirion sensors. Let us know of any questions or difficulties you encountered in the comments below, we'll make sure to reach out!
Advanced users might want to take a look at our GitHub repositories to enhance their projects: with our Arduino I2C libraries you can perform advanced sensor operations such as re-calibration or serial number readout. And with our DIY Bluetooth Gadget library, you can broadcast your sensor's data via Bluetooth and see the measurements on your phone, with the Sensirion MyAmbience app!
Comments