Red Mosquitto: Implement a Noise Sensor With an MQTT Client in an ESP32
Jorge D. Ortiz-Fuentes25 min read • Published Sep 17, 2024 • Updated Sep 17, 2024
FULL APPLICATION
Rate this tutorial
Welcome to another article of the "Adventures in IoT" series. So far, we have defined an end-to-end project, written the firmware for a Raspberry Pi Pico MCU board to measure the temperature and send the value via Bluetooth Low Energy, learned how to use Bluez and D-Bus, and implemented a collecting station that was able to read the BLE data. If you haven't had the time yet, you can read them or watch the videos.
In this article, we are going to write the firmware for a different board: an ESP32-C6-DevKitC-1. ESP32 boards are very popular among the DIY community and for IoT in general. The creator of these boards, Espressif, is putting a good amount of effort into supporting Rust as a first-class developer language for them. I am thankful for that and I will take advantage of the tools they have created for us.
We can write code for the ESP32 that talks to the bare metal, a.k.a. core, or use an operating system that allows us to take full advantage of the capabilities provided by std library. ESP-IDF –i.e., ESPressif IoT Development Framework– is created to simplify that development and is not only available in C/C++ but also in Rust, which we will be using for the rest of this article. By using ESP-IDF through the corresponding crates, we can use threads, mutexes, and other synchronization primitives, collections, random number generation, sockets, etc.
My board is an ESP32-C6 that uses a RISC-V architecture. It doesn't have any built-in sensor, but it features an RGB LED and is capable of communicating wirelessly with the rest of the world in several ways: WiFi, Bluetooth LE, Zigbee, and Thread. Let's see how we can use these features.
On January 9th, 2024 –i.e., a few days after I had started preparing this tutorial–
embedded-hal
v1.0 was released. It provides an abstraction to create drivers that are independent from the MCU. This is very useful for us developers because it allows us to develop and maintain the driver once and use it for the many different MCU boards that honor that abstraction.This development board kit has a neopixel LED –i.e., an RGB LED controlled by a WS2812– which we will use for our "Hello World!" iteration and then to inform the user about the state of the device. The WS2812 requires sending sequences of high and low voltages that use the duration of those high and low values to specify the bits that define the RGB color components of the LED. The ESP32 has a Remote Control Transceiver (RMT) that was conceived as an infrared transceiver but can be repurposed to generate the signals required for the single-line serial protocol used by the WS1812. Neither the RMT nor the timers are available in the just released version of the
embedded-hal
, but the ESP-IDF provided by Expressif does implement the full embedded-hal
abstraction, and the WS2812 driver uses the available abstractions.There are some tools that you will need to have installed in your computer to be able to follow along and compile and install the firmware on your board. I have installed them on my computer, but before spending time on this setup, consider using the container provided by Espressif if you prefer that choice.
The first thing that might be different for you is that we need the bleeding edge version of the Rust toolchain. We will be using the nightly version of it:
1 rustup toolchain install nightly --component rust-src
As for the tools, you may already have some of these tools on your computer, but double-check that you have installed all of them:
- Git (in macOS installed with Code)
- Some tools to assist on the building process (
brew install cmake ninja dfu-util python3
–This works on macOS, but if you use a different OS, please check the list here) - A tool to forward linker arguments to the actual linker (
cargo install ldproxy
) - A utility to write the firmware to the board (
cargo install espflash
) - A tool that is used to produce a new project from a template (
cargo install cargo-generate
)
We can then create a project using the template for
stdlib
projects (esp-idf-template
):1 cargo generate esp-rs/esp-idf-template cargo
And we fill in this data:
- Project name: mosquitto-bzzz
- MCU to target: esp32c6
- Configure advanced template options: false
cargo b
produces the build. Target is riscv32imac-esp-espidf
(RISC-V architecture with support for atomics), so the binary is generated in target/riscv32imac-esp-espidf/debug/mosquitto-bzzz
. And it can be run on the device using this command:1 espflash flash target/riscv32imac-esp-espidf/debug/mosquitto-bzzz --monitor
And at the end of the output log, you can find these lines:
1 I (358) app_start: Starting scheduler on CPU0 2 I (362) main_task: Started on CPU0 3 I (362) main_task: Calling app_main() 4 I (362) mosquitto_bzzz: Hello, world! 5 I (372) main_task: Returned from app_main()
Let's understand the project that has been created so we can take advantage of all the pieces:
- Cargo.toml: It is main the configuration file for the project. Besides what a regular
cargo new
would do, we will see that:- It defines some features available that modify the configuration of some of the dependencies.
- It includes a couple of dependencies: one for the logging API and another for using the ESP-IDF.
- It adds a build dependency that provides utilities for building applications for embedded systems.
- It adjusts the profile settings that modify some compiler options, optimization level, and debug symbols, for debug and release.
- build.rs: A build script that doesn't belong to the application but is executed as part of the build process.
- rust-toolchain.toml: A configuration file to enforce the usage of the nightly toolchain as well as a local copy of the Rust standard library source code.
- sdkconfig.defaults: A file with some configuration parameters for the esp-idf.
- .cargo/config.toml: A configuration file for Cargo itself, where we have the architecture, the tools, and the unstable flags of the compiler used in the build process, and the environment variables used in the process.
- src/main.rs: The seed for our code with the minimal skeleton.
The idea is to create firmware similar to the one we wrote for the Raspberry Pi Pico but exposing the sensor data using MQTT instead of Bluetooth Low Energy. That means that we have to connect to the WiFi, then to the MQTT broker, and start publishing data. We will use the RGB LED to show the status of our sensor and use a sound sensor to obtain the desired data.
Making an LED blink is considered the hello world of embedded programming. We can take it a little bit further and use colors rather than just blink.
- According to the documentation of the board, the LED is controlled by the GPIO8 pin. We can get access to that pin using the
Peripherals
module of the esp-idf-svc, which exposes the hal addinguse esp_idf_svc::hal::peripherals::Peripherals;
:1 let peripherals = Peripherals::take().expect("Unable to access device peripherals"); 2 let led_pin = peripherals.pins.gpio8; - Also using the Peripherals singleton, we can access the RMT channel that will produce the desired waveform signal required to set each of the three color components of the LED:
1 let rmt_channel = peripherals.rmt.channel0; - We could do the RGB color encoding manually, but there is a crate that will help us talk to the built-in WS2812 (neopixel) controller that drives the RGB LED. The create
smart-leds
could be used on top of it if we had several LEDs, but we don't need it for this board.1 cargo add ws2812-esp32-rmt-driver - We create an instance that talks to the WS2812 in pin 8 and uses the Remote Control Transceiver – a.k.a. RMT – peripheral in channel 0. We add the symbol
use ws2812_esp32_rmt_driver::Ws2812Esp32RmtDriver;
and:1 let mut neopixel = 2 Ws2812Esp32RmtDriver::new(rmt_channel, led_pin).expect("Unable to talk to ws2812"); - Then, we define the data for a pixel and write it with the instance of the driver so it gets used in the LED. It is important to not only import the type for the 24bi pixel color but also get the trait with
use ws2812_esp32_rmt_driver::driver::color::{LedPixelColor,LedPixelColorGrb24};
:1 let color_1 = LedPixelColorGrb24::new_with_rgb(255, 255, 0); 2 neopixel 3 .write_blocking(color_1.as_ref().iter().cloned()) 4 .expect("Error writing to neopixel"); - At this moment, you can run it with
cargo r
and expect the LED to be on with a yellow color. - Let's add a loop and some changes to complete our "hello world." First, we define a second color:
1 let color_2 = LedPixelColorGrb24::new_with_rgb(255, 0, 255); - Then, we add a loop at the end where we switch back and forth between these two colors:
1 loop { 2 neopixel 3 .write_blocking(color_1.as_ref().iter().cloned()) 4 .expect("Error writing to neopixel"); 5 neopixel 6 .write_blocking(color_2.as_ref().iter().cloned()) 7 .expect("Error writing to neopixel"); 8 } - If we don't introduce any delays, we won't be able to perceive the colors changing, so we add
use std::{time::Duration, thread};
and wait for half a second before every change:1 neopixel 2 .write_blocking(color_1.as_ref().iter().cloned()) 3 .expect("Error writing to neopixel"); 4 thread::sleep(Duration::from_millis(500)); 5 neopixel 6 .write_blocking(color_2.as_ref().iter().cloned()) 7 .expect("Error writing to neopixel"); 8 thread::sleep(Duration::from_millis(500)); - We run and watch the LED changing color from purple to yellow and back every half a second.
We are going to encapsulate the usage of the LED in its own thread. That thread needs to be aware of any changes in the status of the device and use the current one to decide how to use the LED accordingly.
- First, we are going to need an enum with all of the possible states. Initially, it will contain one variant for no error, one variant for WiFi error, and another one for MQTT error:
1 enum DeviceStatus { 2 Ok, 3 WifiError, 4 MqttError, 5 } - And we can add an implementation to convert from eight-bit unsigned integers into a variant of this enum:
1 impl TryFrom<u8> for DeviceStatus { 2 type Error = &'static str; 3 4 fn try_from(value: u8) -> Result<Self, Self::Error> { 5 match value { 6 0u8 => Ok(DeviceStatus::Ok), 7 1u8 => Ok(DeviceStatus::WifiError), 8 2u8 => Ok(DeviceStatus::MqttError), 9 _ => Err("Unknown status"), 10 } 11 } 12 } - We would like to use the
DeviceStatus
variants by name where a number is required. We achieve the inverse conversion by adding an annotation to the enum:1 2 enum DeviceStatus { - Next, I am going to do something that will be considered naïve by anybody that has developed anything in Rust, beyond the simplest "hello world!" However, I want to highlight one of the advantages of using Rust, instead of most other languages, to write firmware (and software in general). I am going to define a variable in the main function that will hold the current status of the device and share it among the threads.
1 let mut status = DeviceStatus::Ok as u8; - We are going to define two threads. The first one is meant for reporting back to the user the status of the device. The second one is just needed for testing purposes, and we will replace it with some real functionality in a short while. We will be using sequences of colors in the LED to report the status of the sensor. So, let's start by defining each of the steps in those color sequences:
1 struct ColorStep { 2 red: u8, 3 green: u8, 4 blue: u8, 5 duration: u64, 6 } - We also define a constructor as an associated function for our own convenience:
1 impl ColorStep { 2 fn new(red: u8, green: u8, blue: u8, duration: u64) -> Self { 3 ColorStep { 4 red, 5 green, 6 blue, 7 duration, 8 } 9 } 10 } - We can then use those steps to transform each status into a different sequence that we can display in the LED:
1 impl DeviceStatus { 2 fn light_sequence(&self) -> Vec<ColorStep> { 3 match self { 4 DeviceStatus::Ok => vec![ColorStep::new(0, 255, 0, 500), ColorStep::new(0, 0, 0, 500)], 5 DeviceStatus::WifiError => { 6 vec![ColorStep::new(255, 0, 0, 200), ColorStep::new(0, 0, 0, 100)] 7 } 8 DeviceStatus::MqttError => vec![ 9 ColorStep::new(255, 0, 255, 100), 10 ColorStep::new(0, 0, 0, 300), 11 ], 12 } 13 } 14 } - We start the thread by initializing the WS2812 that controls the LED:
1 use esp_idf_svc::hal::{ 2 gpio::OutputPin, 3 peripheral::Peripheral, 4 rmt::RmtChannel, 5 }; 6 7 fn report_status( 8 status: &u8, 9 rmt_channel: impl Peripheral<P = impl RmtChannel>, 10 led_pin: impl Peripheral<P = impl OutputPin>, 11 ) -> ! { 12 let mut neopixel = 13 Ws2812Esp32RmtDriver::new(rmt_channel, led_pin).expect("Unable to talk to ws2812"); 14 loop {} 15 } - We can keep track of the previous status and the current sequence, so we don't have to regenerate it after displaying it once. This is not required, but it is more efficient:
1 let mut prev_status = DeviceStatus::WifiError; // Anything but Ok 2 let mut sequence: Vec<ColorStep> = vec![]; - We then get into an infinite loop, in which we update the status, if it has changed, and the sequence accordingly. In any case, we use each of the steps of the sequence to display it in the LED:
1 loop { 2 if let Ok(status) = DeviceStatus::try_from(*status) { 3 if status != prev_status { 4 prev_status = status; 5 sequence = status.light_sequence(); 6 } 7 for step in sequence.iter() { 8 let color = LedPixelColorGrb24::new_with_rgb(step.red, step.green, step.blue); 9 neopixel 10 .write_blocking(color.as_ref().iter().cloned()) 11 .expect("Error writing to neopixel"); 12 thread::sleep(Duration::from_millis(step.duration)); 13 } 14 } 15 } - Notice that the status cannot be compared until we implement
PartialEq
, and assigning it requires Clone and Copy, so we derive them:1 2 enum DeviceStatus { - Now, we are going to implement the function that is run in the other thread. This function will change the status every 10 seconds. Since this is for the sake of testing the reporting capability, we won't be doing anything fancy to change the status, just moving from one status to the next and back to the beginning:
1 fn change_status(status: &mut u8) -> ! { 2 loop { 3 thread::sleep(Duration::from_secs(10)); 4 if let Ok(current) = DeviceStatus::try_from(*status) { 5 match current { 6 DeviceStatus::Ok => *status = DeviceStatus::WifiError as u8, 7 DeviceStatus::WifiError => *status = DeviceStatus::MqttError as u8, 8 DeviceStatus::MqttError => *status = DeviceStatus::Ok as u8, 9 } 10 } 11 } 12 } - With the two functions in place, we just need to spawn two threads, one with each one of them. We will use a thread scope that will take care of joining the threads that we spawn:
1 thread::scope(|scope| { 2 scope.spawn(|| report_status(&status, rmt_channel, led_pin)); 3 scope.spawn(|| change_status(&mut status)); 4 }); - Compiling this code will result in errors. It is the blessing/curse of the borrow checker, which is capable of figuring out that we are sharing memory in an unsafe way. The status can be changed in one thread while being read by the other. We could use a mutex, as we did in the previous C++ code, and wrap it in an
Arc
to be able to use a reference in each thread, but there is an easier way to achieve the same goal: We can use an atomic type. (use std::sync::atomic::AtomicU8;
)1 let status = &AtomicU8::new(0u8); - We modify
report_status()
to use the reference to the atomic type and adduse std::sync::atomic::Ordering::Relaxed;
:1 fn report_status( 2 status: &AtomicU8, 3 rmt_channel: impl Peripheral<P = impl RmtChannel>, 4 led_pin: impl Peripheral<P = impl OutputPin>, 5 ) -> ! { 6 let mut neopixel = 7 Ws2812Esp32RmtDriver::new(rmt_channel, led_pin).expect("Unable to talk to ws2812"); 8 let mut prev_status = DeviceStatus::WifiError; // Anything but Ok 9 let mut sequence: Vec<ColorStep> = vec![]; 10 loop { 11 if let Ok(status) = DeviceStatus::try_from(status.load(Relaxed)) { - And
change_status()
. Notice that in this case, thanks to the interior mutability, we don't need a mutable reference but a regular one. Also, we need to specify the guaranties in terms of how multiple operations will be ordered. Since we don't have any other atomic operations in the code, we can go with the weakest level – i.e.,Relaxed
:1 fn change_status(status: &AtomicU8) -> ! { 2 loop { 3 thread::sleep(Duration::from_secs(10)); 4 if let Ok(current) = DeviceStatus::try_from(status.load(Relaxed)) { 5 match current { 6 DeviceStatus::Ok => status.store(DeviceStatus::WifiError as u8, Relaxed), 7 DeviceStatus::WifiError => status.store(DeviceStatus::MqttError as u8, Relaxed), 8 DeviceStatus::MqttError => status.store(DeviceStatus::Ok as u8, Relaxed), 9 } 10 } 11 } 12 } - Finally, we have to change the lines in which we spawn the threads to reflect the changes that we have introduced:
1 scope.spawn(|| report_status(status, rmt_channel, led_pin)); 2 scope.spawn(|| change_status(status)); - You can use
cargo r
to compile the code and run it on your board. The lights should be displaying the sequences, which should change every 10 seconds.
It is time to interact with a temperature sensor… Just kidding. This time, we are going to use a sound sensor. No more temperature measurements in this project. Promise.
The sensor I am going to use is an OSEPP Sound-01 that claims to be "the perfect sensor to detect environmental variations in noise." It supports an input voltage from 3V to 5V and provides an analog signal. We are going to connect the signal to pin 0 of the GPIO, which is also the pin for the first channel of the analog-to-digital converter (ADC1_CH0). The other two pins are connected to 5V and GND (+ and -, respectively).
You don't have to use this particular sensor. There are many other options on the market. Some of them have pins for digital output, instead of just an analog one as in this one. Some sensors also have a potentiometer that allows you to adjust the sensitivity of the microphone.
- We are going to perform this task in a new function:
1 fn read_noise_level() -> ! { 2 } - We want to use the ADC on the pin that we have connected the signal. We can get access to the ADC1 using the
peripherals
singleton in the main function.1 let adc = peripherals.adc1; - And also to the pin that will receive the signal from the sensor:
1 let adc_pin = peripherals.pins.gpio0; - We modify the signature of our new function to accept the parameters we need:
1 fn read_noise_level<GPIO>(adc1: ADC1, adc1_pin: GPIO) -> ! 2 where 3 GPIO: ADCPin<Adc = ADC1>, - Now, we use those two parameters to attach a driver that can be used to read from the ADC. Notice that the
AdcDriver
needs a configuration, which we create with the default value. Also,AdcChannelDriver
requires a generic const parameter that is used to define the attenuation level. I am going to go with maximum attenuation initially to have more sensibility in the mic, but we can change it later if needed. We adduse esp_idf_svc::hal::adc::{attenuation, AdcChannelDriver};
:1 let mut adc = 2 AdcDriver::new(adc1, &adc::config::Config::default()).expect("Unable to initialze ADC1"); 3 let mut adc_channel_drv: AdcChannelDriver<{ attenuation::DB_11 }, _> = 4 AdcChannelDriver::new(adc1_pin).expect("Unable to access ADC1 channel 0"); - With the required pieces in place, we can use the
adc_channel
to sample in an infinite loop. A delay of 10ms means that we will be sampling at ~100Hz:1 loop { 2 thread::sleep(Duration::from_millis(10)); 3 println!("ADC value: {:?}", adc.read(&mut adc_channel)); 4 } - Lastly, we spawn a thread with this function in the same scope that we were using before:
1 scope.spawn(|| read_noise_level(adc, adc_pin));
In order to get an estimation of the noise level, I am going to compute the Root Mean Square (RMS) of a buffer of 50ms, i.e., five samples at our current sampling rate. Yes, I know this isn't exactly how decibels are measured, but it will be good enough for us and the data that we want to gather.
- Let's start by creating that buffer where we will be putting the samples:
1 const LEN: usize = 5; 2 let mut sample_buffer = [0u16; LEN]; - Inside the infinite loop, we are going to have a for-loop that goes through the buffer:
1 for i in 0..LEN { 2 } - We modify the sampling that we were doing before, so a zero value is used if the ADC fails to get a sample:
1 thread::sleep(Duration::from_millis(10)); 2 if let Ok(sample) = adc.read(&mut adc_pin) { 3 sample_buffer[i] = sample; 4 } else { 5 sample_buffer[i] = 0u16; 6 } - Before starting with the iterations of the for loop, we are going to define a variable to hold the addition of the squares of the samples:
1 let mut sum = 0.0f32; - And each sample is squared and added to the sum. We could do the conversion into floats after the square, but then, the square value might not fit into a u16:
1 sum += (sample as f32) * (sample as f32); - And we compute the decibels (or something close enough to that) after the for loop:
1 let d_b = 20.0f32 * (sum / LEN as f32).sqrt().log10(); 2 println!( 3 "ADC values: {:?}, sum: {}, and dB: {} ", 4 sample_buffer, sum, d_b 5 ); - We compile and run with
cargo r
and should get some output similar to:1 ADC values: [0, 0, 0, 0, 0], sum: 0, and dB: -inf 2 ADC values: [0, 0, 0, 3, 0], sum: 9, and dB: 2.5527248 3 ADC values: [0, 0, 0, 11, 0], sum: 121, and dB: 13.838154 4 ADC values: [8, 0, 38, 0, 102], sum: 11912, and dB: 33.770145 5 ADC values: [64, 23, 0, 8, 26], sum: 5365, and dB: 30.305998 6 ADC values: [0, 8, 41, 0, 87], sum: 9314, and dB: 32.70166 7 ADC values: [137, 0, 79, 673, 0], sum: 477939, and dB: 49.804024 8 ADC values: [747, 0, 747, 504, 26], sum: 1370710, and dB: 54.379753 9 ADC values: [240, 0, 111, 55, 26], sum: 73622, and dB: 41.680374 10 ADC values: [8, 26, 26, 58, 96], sum: 13996, and dB: 34.470337
When we wrote our previous firmware, we used Bluetooth Low Energy to make the data from the sensor available to the rest of the world. That was an interesting experiment, but it had some limitations. Some of those limitations were introduced by the hardware we were using, like the fact that we were getting some interferences in the Bluetooth signal from the WiFi communications in the Raspberry Pi. But others are inherent to the Bluetooth technology, like the maximum distance from the sensor to the collecting station.
For this firmware, we have decided to take a different approach. We will be using WiFi for the communications from the sensors to the collecting station. WiFi will allow us to spread the sensors through a much greater area, especially if we have several access points. However, it comes with a price: The sensors will consume more energy and their batteries will last less.
Using WiFi practically implies that our communications will be TCP/IP-based. And that opens a wide range of possibilities, which we can summarize with this list in increasing order of likelihood:
- Implement a custom TCP or UDP protocol.
- Use an existing protocol that is commonly used for writing APIs. There are other options, but HTTP is the main one here.
- Use an existing protocol that is more tailored for the purpose of sending event data that contains values.
Creating a custom protocol is expensive, time-consuming, and error-prone, especially without previous experience. It''s probably the worst idea for a proof of concept unless you have a very specific requirement that cannot be accomplished otherwise.
HTTP comes to mind as an excellent solution to exchange data. REST APIs are an example of that. However, it has some limitations, like the unidirectional flow of data, the overhead –both in terms of the protocol itself and on using a new connection for every new request– and even the lack of provision to notify selected clients when the data they are interested in changes.
If we want to go with a protocol that was designed for this, MQTT is the natural choice. Besides overcoming the limitations of HTTP for this type of communication, it has been tested in the field with many sensors that change very often and out of the box, can do fancy things like storing the last known good value or having specific client commands that allow them to receive updates on specific values or a set of them. MQTT is designed as a protocol for publish/subscribe (pub/sub) in the scenarios that are common for IoT. The server that controls all the communications is commonly referred to as a broker, and our sensors will be its clients.
Now that we have a better understanding of why we are using MQTT, we are going to connect to our broker and send the data that we obtain from our sensor so it gets published there.
However, before being able to do that, we need to connect to the WiFi.
It is important to keep in mind that the board we are using has support for WiFi but only on the 2.4GHz band. It won't be able to connect to your router using the 5GHz band, no matter how kindly you ask it to do it.
Also, unless you are a wealthy millionaire and you've got yourself a nice island to focus on following along with this content, it would be wise to use a fairly strong password to keep unauthorized users out of your network.
- We are going to begin by setting some structure for holding the authentication data to access the network:
1 struct Configuration { 2 wifi_ssid: &'static str, 3 wifi_password: &'static str, 4 } - We could set the values in the code, but I like better the approach suggested by Ferrous Systems. We will be using the
toml_cfg
crate. We will have default values (useless in this case other than to get an error) that we will be overriding by using a toml file with the desired values. First things first: Let's add the crate:1 cargo add toml-cfg - Let's now annotate the struct with some macros:
1 2 struct Configuration { 3 4 wifi_ssid: &'static str, 5 6 wifi_password: &'static str, 7 } - We can now add a
cfg.toml
file with the actual values of these parameters.1 [mosquitto-bzzz] 2 wifi_ssid = "ThisAintEither" 3 wifi_password = "NorIsThisMyPassword" - Please, remember to add that filename to the
.gitignore
configuration, so it doesn't end up in our repository with our dearest secrets:1 echo "cfg.toml" >> .gitignore - The code for connecting to the WiFi is a little bit tedious. It makes sense to do it in a different function:
1 fn connect_to_wifi(ssid: &str, passwd: &str) {} - This function should have a way to let us know if there has been a problem, but we want to simplify error handling, so we add the
anyhow
crate:1 cargo add anyhow - We can now use the
Result
type provided by anyhow (import anyhow::Result;
). This way, we don't need to be bored with creating and using a custom error type.1 fn connect_to_wifi(ssid: &str, passwd: &str) -> Result<()> { 2 Ok(()) 3 } - If the function doesn't get an SSID, it won't be able to connect to the WiFi, so it's better to stop here and return an error (
import anyhow::bail;
):1 if ssid.is_empty() { 2 bail!("No SSID defined"); 3 } - If the function gets a password, we will assume that authentication uses WPA2. Otherwise, no authentication will be used (
use esp_idf_svc::wifi::AuthMethod;
):1 let auth_method = if passwd.is_empty() { 2 AuthMethod::None 3 } else { 4 AuthMethod::WPA2Personal 5 }; - We will need an instance of the system loop to maintain the connection to the WiFi alive and kicking, so we access the system event loop singleton (
use esp_idf_svc::eventloop::EspSystemEventLoop;
anduse anyhow::Context
).1 let sys_loop = EspSystemEventLoop::take().context("Unable to access system event loop.")?; - Although it is not required, the esp32 stores some data from previous network connections in the non-volatile storage, so getting access to it will simplify and accelerate the connection process (
use esp_idf_svc::nvs::EspDefaultNvsPartition;
).1 let nvs = EspDefaultNvsPartition::take().context("Unable to access default NVS partition")?; - The connection to the WiFi is done through the modem, which can be accessed via the peripherals of the board. We pass the peripherals, obtain the modem, and use it to first wrap it with a WiFi driver and then get an instance that we will use to manage the WiFi connection (
use esp_idf_svc::wifi::{EspWifi, BlockingWifi};
):1 fn connect_to_wifi(ssid: &str, passwd: &str, 2 modem: impl Peripheral<P = modem::Modem> + 'static, 3 ) -> Result<()> { 4 // Auth checks here and sys_loop ... 5 let mut esp_wifi = EspWifi::new(modem, sys_loop.clone(), Some(nvs))?; 6 let mut wifi = BlockingWifi::wrap(&mut esp_wifi, sys_loop)?; - Then, we add a configuration to the WiFi (
use esp_idf_svc::wifi;
):1 wifi.set_configuration(&mut wifi::Configuration::Client( 2 wifi::ClientConfiguration { 3 ssid: ssid 4 .try_into() 5 .map_err(|_| anyhow::Error::msg("Unable to use SSID"))?, 6 password: passwd 7 .try_into() 8 .map_err(|_| anyhow::Error::msg("Unable to use Password"))?, 9 auth_method, 10 ..Default::default() 11 }, 12 ))?; - With the configuration in place, we start the WiFi radio, connect to the WiFi network, and wait to have the connection completed. Any errors will bubble up:
1 wifi.start()?; 2 wifi.connect()?; 3 wifi.wait_netif_up()?; - It is useful at this point to display the data of the connection.
1 let ip_info = wifi.wifi().sta_netif().get_ip_info()?; 2 log::info!("DHCP info: {:?}", ip_info); - We also want to return the variable that holds the connection. Otherwise, the connection will be closed when it goes out of scope at the end of this function. We change the signature to be able to do it:
1 ) -> Result<Box<EspWifi<'static>>> { - And return that value:
1 Ok(Box::new(wifi_driver)) - We are going to initialize the connection to the WiFi from our function to read the noise, so let's add the modem as a parameter:
1 fn read_noise_level<GPIO>( 2 adc1: ADC1, 3 adc1_pin: GPIO, 4 modem: impl Peripheral<P = modem::Modem> + 'static, 5 ) -> ! - This new parameter has to be initialized in the main function:
1 let modem = peripherals.modem; - And passed it onto the function when we spawn the thread:
1 scope.spawn(|| read_noise_level(adc, adc_pin, modem)); - Inside the function where we plan to use these parameters, we retrieve the configuration. The
CONFIGURATION
constant is generated automatically by thecfg-toml
crate using the type of the struct:1 let app_config = CONFIGURATION; - Next, we try to connect to the WiFi using those parameters:
1 let _wifi = match connect_to_wifi(app_config.wifi_ssid, app_config.wifi_password, modem) { 2 Ok(wifi) => wifi, 3 Err(err) => { 4 5 } 6 }; - And, when dealing with the error case, we change the value of the status:
1 log::error!("Connect to WiFi: {}", err); 2 status.store(DeviceStatus::WifiError as u8, Relaxed); - This function doesn't take the state as an argument, so we add it to its signature:
1 fn read_noise_level<GPIO>( 2 status: &AtomicU8, - That argument is provided when the thread is spawned:
1 scope.spawn(|| read_noise_level(status, adc, adc_pin, modem)); - We don't want the status to be changed sequentially anymore, so we remove that thread and the function that was implementing that change.
- We run this code with
cargo r
to verify that we can connect to the network. However, this version is going to crash. 😱 Our function is going to exceed the default stack size for a thread, which, by default, is 4Kbytes. - We can use a thread builder, instead of the
spawn
function, to change the stack size:1 thread::Builder::new() 2 .stack_size(6144) 3 .spawn_scoped(scope, || read_noise_level(status, adc, adc_pin, modem)) 4 .unwrap(); - After performing this change, we run it again
cargo r
and it should work as expected.
The next step after connecting to the WiFi is to connect to the MQTT broker as a client, but we don't have an MQTT broker yet. In this section, I will show you how to install Mosquitto, which is an open-source project of the Eclipse Foundation.
- For this section, we need to have an MQTT broker. In my case, I will be installing Mosquitto, which implements versions 3.1.1 and 5.0 of the MQTT protocol. It will run in the same Raspberry Pi that I am using as a collecting station.
1 sudo apt-get update && sudo apt-get upgrade 2 sudo apt-get install -y {mosquitto,mosquitto-clients,mosquitto-dev} 3 sudo systemctl enable mosquitto.service - We modify the Mosquitto configuration to enable clients to connect from outside of the localhost. We need some credentials and a configuration that enforces authentication:
1 sudo mosquitto_passwd -c -b /etc/mosquitto/passwd soundsensor "Zap\!Pow\!Bam\!Kapow\!" 2 sudo sh -c 'echo "listener 1883\nallow_anonymous false\npassword_file /etc/mosquitto/passwd" > /etc/mosquitto/conf.d/remote_access.conf' 3 sudo systemctl restart mosquitto - Let's test that we can subscribe and publish to a topic. The naming convention tends to use lowercase letters, numbers, and dashes only and reserves dashes for separating topics hierarchically. On one terminal, subscribe to the
testTopic
:1 mosquitto_sub -t test/topic -u soundsensor -P "Zap\!Pow\!Bam\!Kapow\!" - And on another terminal, publish something to it:
1 mosquitto_pub -d -t test/topic -m "Hola caracola" -u soundsensor -P "Zap\!Pow\!Bam\!Kapow\!" - You should see the message that we wrote on the second terminal appear on the first one. This means that Mosquitto is running as expected.
With the MQTT broker installed and ready, we can write the code to connect our sensor to it as an MQTT client and publish its data.
- We are going to need the credentials that we have just created to publish data to the MQTT broker, so we add them to the
Configuration
structure:1 2 struct Configuration { 3 4 wifi_ssid: &'static str, 5 6 wifi_password: &'static str, 7 8 mqtt_host: &'static str, 9 10 mqtt_user: &'static str, 11 12 mqtt_password: &'static str, 13 } - You have to remember to add the values that make sense to the
cfg.toml
file for your environment. Don't expect to get them from my repo, because we have asked Git to ignore this file. At the very least, you need the hostname or IP address of your MQTT broker. Copy the user name and password that we created previously:1 [mosquitto-bzzz] 2 wifi_ssid = "ThisAintEither" 3 wifi_password = "NorIsThisMyPassword" 4 mqtt_host = "mqttsystem" 5 mqtt_user = "soundsensor" 6 mqtt_password = "Zap!Pow!Bam!Kapow!" - Coming back to the function that we have created to read the noise sensor, we can now initialize an MQTT client after connecting to the WiFi (
use mqtt::client::{EspMqttClient, MqttClientConfiguration, QoS},
):1 let mut mqtt_client = 2 EspMqttClient::new() 3 .expect("Unable to initialize MQTT client"); - The first parameter is a URL to the MQTT server that will include the user and password, if defined:
1 let mqtt_url = if app_config.mqtt_user.is_empty() || app_config.mqtt_password.is_empty() { 2 format!("mqtt://{}/", app_config.mqtt_host) 3 } else { 4 format!( 5 "mqtt://{}:{}@{}/", 6 app_config.mqtt_user, app_config.mqtt_password, app_config.mqtt_host 7 ) 8 }; - The second parameter is the configuration. Let's add them to the creation of the MQTT client:
1 EspMqttClient::new(&mqtt_url, &MqttClientConfiguration::default(), |_| { 2 log::info!("MQTT client callback") 3 }) - In order to publish, we need to define the topic:
1 const TOPIC: &str = "home/noise sensor/01"; - And a variable that will be used to contain the message that we will publish:
1 let mut mqtt_msg: String; - Inside the loop, we will format the noise value because it is sent as a string:
1 mqtt_msg = format!("{}", d_b); - We publish this value using the MQTT client:
1 if let Ok(msg_id) = mqtt_client.publish(TOPIC, QoS::AtMostOnce, false, mqtt_msg.as_bytes()) 2 { 3 println!( 4 "MSG ID: {}, ADC values: {:?}, sum: {}, and dB: {} ", 5 msg_id, sample_buffer, sum, d_b 6 ); 7 } else { 8 println!("Unable to send MQTT msg"); 9 } - As we did when we were publishing from the command line, we need to subscribe, in an independent terminal, to the topic that we plan to publish to. In this case, we are going to start with
home/noise sensor/01
. Notice that we represent a hierarchy, i.e., there are noise sensors at home and each of the sensors has an identifier. Also, notice that levels of the hierarchy are separated by slashes and can include spaces in their names.1 mosquitto_sub -t "home/noise sensor/01" -u soundsensor -P "Zap\!Pow\!Bam\!Kapow\!" - Finally, we compile and run the firmware with
cargo r
and will be able to see those values appearing on the terminal that is subscribed to the topic.
I would like to finish this firmware solving a problem that won't show up until we have two sensors or more. Our firmware uses a constant topic. That means that two sensors with the same firmware will use the same topic and we won't have a way to know which value corresponds to which sensor. A better option is to use a unique identifier that will be different for every ESP32-C6 board. We can use the MAC address for that.
- Let's start by creating a function that returns that identifier:
1 fn get_sensor_id() -> String { 2 } - Our function is going to use an unsafe function from ESP-IDF, and format the result as a
String
(use esp_idf_svc::sys::{esp_base_mac_addr_get, ESP_OK};
anduse std::fmt::Write
). The function that returns the MAC address uses a pointer and, having been written in C++, couldn't care less about the safety rules that Rust code must obey. That function is considered unsafe and, as such, Rust requires us to use it within anunsafe
scope. It is their way to tell us, "Here be dragons… and you know about it":1 let mut mac_addr = [0u8; 8]; 2 unsafe { 3 match esp_base_mac_addr_get(mac_addr.as_mut_ptr()) { 4 ESP_OK => { 5 let sensor_id = mac_addr.iter().fold(String::new(), |mut output, b| { 6 let _ = write!(output, "{b:02x}"); 7 output 8 }); 9 log::info!("Id: {:?}", sensor_id); 10 sensor_id 11 } 12 _ => { 13 log::error!("Unable to get id."); 14 String::from("BADCAFE00BADBEEF") 15 } 16 } 17 } - Then, we use the function before defining the topic and use its result with it:
1 let sensor_id = get_sensor_id(); 2 let topic = format!("home/noise sensor/{sensor_id}"); - And we slightly change the way we publish the data to use the topic:
1 if let Ok(msg_id) = mqtt_client.publish(&topic, QoS::AtMostOnce, false, mqtt_msg.as_bytes()) - We also need to change the subscription so we listen to all the topics that start with
home/sensor/
and have one more level:1 mosquitto_sub -t "home/noise sensor/+" -u soundsensor -P "Zap\!Pow\!Bam\!Kapow\!" - We compile and run with
cargo r
and the values start showing up on the terminal where the subscription was initiated.
In this article, we have used Rust to write the firmware for an ESP32-C6-DevKitC-1 board from beginning to end. Although we can agree that Python was an easier approach for our first firmware, I believe that Rust is a more robust, approachable, and useful language for this purpose.
The firmware that we have created can inform the user of any problems using an RGB LED, measure noise in something close enough to deciBels, connect our board to the WiFi and then to our MQTT broker as a client, and publish the measurements of our noise sensor. Not bad for a single tutorial.
We have even gotten ahead of ourselves and added some code to ensure that different sensors with the same firmware publish their values to different topics. And to do so, we have done a very brief incursion in the universe of unsafe Rust and survived the wilderness. Now you can go to a bar and tell your friends, "I wrote unsafe Rust." Well done!
In our next article, we will be writing C++ code again to collect the data from the MQTT broker and then send it to our instance of MongoDB Atlas in the Cloud. So get ready!
Top Comments in Forums
There are no comments on this article yet.