Smart sensors

Smart Sensors with Erlang and AtomVM: Smart cities, smart houses and manufacturing monitoring

For our first article on IoT developments at Erlang Solutions, our goal is to delve into the use of Erlang on microcontrollers, highlighting and exposing its capabilities to run efficiently on smaller devices. For our inaugural article, we have chosen to address a pressing issue faced by numerous sectors- including healthcare, real estate management, travel, entertainment, and hospitality industries: air quality monitoring. The range of measurements that can be collected is vast and will vary from context to context so we decided to use just one example of the information that can be collected as a conversation starter.

We will guide you through the challenges and demonstrate how Erlang/Elixir can be utilised to measure, analyse, make smart decisions, respond accordingly and evaluate the results.

Air quality is assessed by reading a range of different metrics. Carbon dioxide (CO₂) concentration, particulate matter (PM), nitrogen dioxide (NO₂), ozone (O₃), carbon monoxide (CO) and sulfur dioxide (SO₂) are usually taken into account. This collection of metrics is currently referred to as VOC (volatile organic compounds). Some, but not all VOCs are human-made and are produced in different processes, whether by urbanisation, manufacturing or during the production of other goods or services.

We are measuring CO₂ in this prototype as an example for gathering environmental readings. CO₂ is a greenhouse gas naturally present in the atmosphere and its levels are influenced by many factors, including human activities such as burning fossil fuels.

The specific technical challenge for this prototype was to run our application in very small and power-constrained scenarios. We choose to address this by trying out AtomVM as our alternative to the BEAM.

AtomVM is a new, lightweight implementation of the BEAM virtual machine that is designed to run as a standalone Unix binary or can be embedded in microcontrollers such as STM32, ESP32 and RP2040.

Unlike a single-board computer designed to run an operating system, a microcontroller is not purpose-built for a specific task. Instead, it runs application-specific firmware, often with very low power consumption and with lower costs, making it ideal for operating IoT devices.

Our device is composed of an ESP32 microcontroller, a BME280 sensor to measure pressure, temperature and relative humidity and an SDC40 sensor to measure CO2 PPM (Parts per Million).

The ESP32 that we are going to use in this article is an ESP32-C3, which is a low-cost single-core RISC-V microcontroller, obtainable from authorized distributors worldwide. The SDC40 sensor is a Sensirion and the BME280 sensor is a Bosch Sensortec. There might be cheaper manufacturers for those sensors, so feel free to choose according to your needs.

Let’s get going!

Getting dependencies ready

For starters, we will need to have AtomVM installed, just follow the instructions on their website.

It is important to follow the instructions as it guarantees that you will have a working Expressif ESP-IDF installation and you are able to flash the ESP32 microcontroller via the USB port using the esptool provided by the ESP-SDK tool suite.
You will also need to have rebar3 installed as we are going to use it to manage the development cycle of the project.

Bootstrapping our application

First, we will need to create our application in order to start wiring things up on the software side. Use rebar3 for creating the application layout:

% rebar3 new app name=co2
===> Writing co2/src/co2_app.erl
===> Writing co2/src/co2_sup.erl
===> Writing co2/src/co2.app.src
===> Writing co2/rebar.config
===> Writing co2/.gitignore
===> Writing co2/LICENSE.md
===> Writing co2/README.md

Make sure to include the rebar3 plugins and dependencies before compiling the scaffold project by adding the following to your rebar.config file:

{deps, [
    {atomvm_lib, {git, "https://github.com/atomvm/atomvm_lib.git", {branch, "master"}}}
]}.

{plugins, [
    atomvm_rebar3_plugin
]}.

Recent atomvm_lib development updates have not yet been published to hex.pm, so we use the master branch which has some fixes we need. Lastly, this dependency includes the BME280 driver that we are going to use.

While we can boot the application now in our machine, we also need to implement an extra function that AtomVM will use as an entrypoint. The OTP entrypoint is defined in the co2.app.src file as {mod, {co2_app, []}}, which is the default module to use for starting an application. However, in AtomVM, we need to instruct the runtime to use a start/0 function defined within a module. That is, AtomVM does not start the same way standard OTP applications do. Therefore, some glue must be used:

-module(co2).

-export([start/0]).

start() ->
    {ok, I2CBus} = i2c_bus:start(#{sda => 6, scl => 7}), %% I2C pins for the xiao esp32c3
    {ok, SCD} = scd40:start_link(I2CBus, [{is_active, true}]),
    {ok, BME} = bme280:start(I2CBus, [{address, 16#77}]),
    loop(#{scd => SCD, bme => BME}).

loop(#{scd := SCD, bme := BME} = State) ->
    timer:sleep(5_000),
    {ok, {CO2, Temp, Hum}} = scd40:take_reading(SCD),
    {ok, {Temp1, Press, Hum1}} = bme280:take_reading(BME),
    io:format(
       "[SCD] CO2: ~p PPM, Temperature: ~p C, Humidity: ~p%RH~n",
       [CO2, Temp, Hum]
      ),
    io:format(
      "[BME] Pressure: ~p hPa, Temperature: ~p C, Humidity: ~p%RH~n",
       [Press, Temp1, Hum1] 
      ),
    loop(State).

This module will start the main loop that reads from the sensors and displays the readings over the serial connection.

We are using the stock BME280 driver that comes bundled with the atomvm_lib dependency, meaning that we only needed to change the address in which the BME280 sensor answers in the I2C bus.
For the SCD40 sensor, we need to write some code. According to the SCD40 datasheet, in order to submit commands to the sensor, we need to wrap our commands with a START and STOP condition, signaling the transmission sequence. The sensor provides a range of features and functionality, but we are only concerned with starting periodic measurements and reading those values from the sensor’s memory buffer.

%% 3.5.1 start_periodic_measurement
do_start_periodic_measurement(#state{i2c_bus = I2CBus, address = Address}) ->
    batch_writes(I2CBus, Address, ?SCD4x_CMD_START_PERIODIC_MEASUREMENT),
    timer:sleep(500),
    ok.

…

batch_writes(I2CBus, Address, Register) ->
    Writes =
	[
	 fun(I2C, _Addr) -> i2c:write_byte(I2C, Register bsr 8) end,     %% MSB
	 fun(I2C, _Addr) -> i2c:write_byte(I2C, Register band 16#FF) end %% LSB
	],
    i2c_bus:enqueue(I2CBus, Address, Writes).

Once the SCD40 starts measuring the environment periodically we can read from the sensor every time a new reading is stored in memory:

%% 3.5.2 read_measurement
read_measurement(#state{i2c_bus = I2CBus, address = Address}) ->
    write_byte(I2CBus, Address, ?SCD4x_CMD_READ_MEASUREMENT bsr 8),
    write_byte(I2CBus, Address, ?SCD4x_CMD_READ_MEASUREMENT band 16#FF),
    timer:sleep(1_000),
    case read_bytes(I2CBus, Address, 9) of
	{ok,
	 <<C:2/bytes-little, _CCRC:1/bytes-little,
	   T:2/bytes-little, _TCRC:1/bytes-little,
	   H:2/bytes-little, _HCRC:1/bytes-little>>} ->
	    %% 2 bytes in little endian for co2
	    %% 2 bytes in little endian for temp
	    %% 2 bytes in little endian for humidity
	    <<C1, C2>> = C,
	    <<T1, T2>> = T,
	    <<H1, H2>> = H,
	    {ok, {(C1 bsl 8) bor C2,
		  -45 + 175 * (((T1 bsl 8) bor T2) / math:pow(2, 16)),
		  100 * (((H1 bsl 8) bor H2) / math:pow(2, 16))}};
	{error, _Reason} = Err ->
	    Err
    end.


According to the datasheet, the response we get from memory are 9 bytes that we need to unpack and convert. The response includes an 8-bit CRC checksum that we don’t take into account, but it would be useful to validate the sensor’s response. All the conversions above are according to the official datasheet’s basic command specifications.

Flashing our application

In order to get our application packed in AVM format and flashed to the esp32, we will need to add a rebar3 plugin that handles all those steps for us. It is possible to perform these steps manually but it can become tedious and error prone. By using rebar3 again we gain access to a more streamlined development process.

Add the following to your rebar.config: 

{plugins, [
    {atomvm_rebar3_plugin, {git, "https://github.com/atomvm_rebar3_plugin", {branch, "master"}}}
]}.




Which will provide a few commands that we will use, mainly `esp32_flash` and `packbeam`. Due to the way the the plugin is implemented, calling `esp32_flash` will proceed on getting project dependencies, getting the application compiled and it’s beam files packed in an AVM format designed to be flashed on our device:

% rebar3 esp32_flash --port /dev/tty.usbmodem2101





Note: You must use a port that matches your own.

Obtaining readings

If everything goes according to plan we should be able to connect to our device and see the output of the readings over the serial port. But first, we need to issue the following command within the AtomVM/src/platforms/esp32 directory:

% ESPPORT=/dev/tty.usbmodem2101 idf.py -b 115200 monitor


Note: You must use a port that matches your own.

The output should match something along these lines:

[SCD] CO2: 848 PPM, Temperature: 2.91405487060546875000e+01 C, Humidity: 3.74832153320312500000e+01%RH
[BME] Pressure: 7.46429999999999949978e+02 hPa, Temperature: 2.89406427826446881000e+01 C, Humidity: 3.84759374625173900000e+01%RH



Closing remarks

We have explored writing an Erlang application that can run on an ESP32 microcontroller using AtomVM, an alternate implementation of the BEAM. We also managed to read environmental metrics of our interest, such as temperature, humidity and CO2 for further processing.

Our highlights include the possibility for manipulating binary data by using pattern matching and developer happiness.

The ability to run Erlang applications on microcontrollers opens up a wide range of possibilities for IoT development. Erlang is a well-known language for building reliable and scalable applications, and its use on microcontrollers can help to ensure that these applications are able to handle the demands that IoT requires.

External links

Keep reading

Meet the team: Erik Schön
Meet the Team: Erik Schön thumbnail

Meet the team: Erik Schön

Meet Erik Schön, Managing Director and and Nordics Business Unit Lead at Erlang Solutions. He shares his 2025 highlights and festive traditions.

Optimising for Concurrency: Comparing and contrasting the BEAM and JVM virtual machines

Optimising for Concurrency: Comparing and contrasting the BEAM and JVM virtual machines

Attila Sragli explores the BEAM VM's inner workings, comparing them to the JVM to highlight their importance.

MongooseIM 6.3: Prometheus, CockroachDB and more

MongooseIM 6.3: Prometheus, CockroachDB and more

Pawel Chrząszcz introduces MongooseIM 6.3.0 with Prometheus monitoring and CockroachDB support for greater scalability and flexibility.