This blog post is the fourth of a multi-part series of posts about Rust embassy for the STM32. This post is going to explore communicating over I2C using the embassy HAL. Please be aware that certain concepts in newer posts could depend on concepts in prior posts.
Prior posts include (in order of publishing):
Introduction
In this post, I will be configuring an STM32 I2C peripheral using the embassy HAL to collect ambient temperature measurement data from the BMP180 Digital Pressure Sensor. Note that the BMP180 provides both pressure and temperature data but I will only be collecting the latter. Temperature measurements will be continuously collected and sent to a PC terminal over UART. This code will be built without using any interrupts. This means that I will be leveraging only the embassy HAL without the executor or the async
framework.
π¨ Important Note:
The BMP180 provides both temperature and pressure sensor data which are collected in a very similar manner. I elected to collect only temperature data since the conversion equations are less. My target in this blog post is to focus on operating I2C more than the features of the BMP180 itself. However, the provided code can be easily expanded to collect and convert pressure data as well. I probably will be expanding the code anyway to create a BMP180 device driver at a later time.
π Knowledge Pre-requisites
To understand the content of this post, you need the following:
Basic knowledge of coding in Rust.
Familiarity with the basic template for creating embedded applications in Rust.
Familiarity with I2C communication basics.
Familiarity with UART communication basics.
πΎ Software Setup
All the code presented in this post in addition to instructions for the environment and toolchain setup are available on the apollolabsdev Nucleo-F401RE git repo. Note that if the code on the git repo is slightly different then it means that it was modified to enhance the code quality or accommodate any HAL/Rust updates.
In addition to the above, you would need to install some sort of serial communication terminal on your host PC. Some recommendations include:
For Windows:
For Mac and Linux:
Apart from Serial Studio, some detailed instructions for the different operating systems are available in the Discovery Book.
For me, Serial Studio comes highly recommended. I personally came across Serial Studio recently and found it to be awesome for two main reasons. First is that you can skip many of those instructions for other tools, especially in Mac and Linux systems. Second, if you are you want to graph data over UART, it has a really nice and easy-to-configure setup. It's also open-source and free to use.
π Hardware Setup
π Materials
π Connections
BMP180 module SCL pin connected to Nucleo board pin PB8.
BMP180 module SDA pin connected to Nucleo board pin PB9.
BMP180 module Vcc pin connected to Nucleo board 3.3V.
BMP180 module GND pin connected to Nucleo board GND.
The UART Tx line that connects to the PC through the onboard USB bridge is via pin PA2 on the microcontroller. This is a hardwired pin, meaning you cannot use any other for this setup. Unless you are using a different board other than the Nucleo-F401RE, you have to check the relevant documentation (reference manual or datasheet) to determine the number of the pin.
π¨βπ¨Software Design
The software design for this application can be obtained directly from the BMP180 datasheet. The datasheet for the BMP180 provides a full flow chart describing the algorithmic steps. The flow chart also provides a list of conversion formulas needed to calculate the compensated temperature and pressure. The display temperature value state at the end of the flow would be the step where I would send the result over UART.
From the above flow chart, I will only be implementing the parts to do with temperature collection. The same code can be easily expanded to pressure as well. One would only have to implement the additional equations.
π¨βπ» Code Implementation
π₯ Crate Imports
In this implementation, the following crates are required:
The
cortex_m_rt
crate for startup code and minimal runtime for Cortex-M microcontrollers.The
heapless
crate to import and create a fixed capacityString
.The
core::fmt
crate will allow us to use thewriteln!
macro for easy printing.The
panic_halt
crate to define the panicking behavior to halt on panic.The
embassy_time
crate to import timekeeping capabilities.The
embassy_stm32
crate to import the embassy STM32 series microcontroller device hardware abstractions. The needed abstractions are imported accordingly.The
panic_halt
crate to define the panicking behavior to halt on panic.
use core::fmt::Write;
use cortex_m::prelude::_embedded_hal_blocking_delay_DelayMs;
use heapless::String;
use cortex_m_rt::entry;
use embassy_stm32::dma::NoDma;
use embassy_stm32::i2c::I2c;
use embassy_stm32::interrupt;
use embassy_stm32::time::hz;
use embassy_stm32::usart::{Config, UartTx};
use embassy_time::Delay;
use panic_halt as _;
π Peripheral Configuration Code
I2C Peripheral Configuration:
1οΈβ£ Initialize MCU and obtain a handle for the device peripherals: A device peripheral handler p
is created:
let p = embassy_stm32::init(Default::default());
2οΈβ£ Configure the I2C peripheral channel: Looking into the Nucleo-F401RE board pinout, the SDA and SCL lines pins PB8 and PB9 connect to the I2C1 peripheral in the microcontroller device. As such, this means we need to configure I2C1 and somehow pass it to the handles of the pins we want to use.
In the example I am creating in this post, I will be using I2C only in a blocking manner. This would typically mean that no interrupts are required as all tasks would be polled. Though in configuring I2C in embassy, as it stands, it seems that an interrupt still needs to be attached although we will not be using it. This means that an additional step is required ahead of configuring I2C. In order to attach an interrupt to the I2C configuration, a handle for the interrupt source needs to be created using the take!
macro. There are also two interrupt sources listed for I2C in embassy, I2C2_EV
(for I2C interrupt event) and I2C2_ER
(for I2C interrupt error). The interrupt handle irq
is created as follows:
let irq = interrupt::take!(I2C1_EV);
In the embassy I2C driver documentation a new
method exists to configure I2C and looks as follows:
pub fn new(
peri: impl Peripheral<P = T> + 'd,
scl: impl Peripheral<P = impl SclPin<T>> + 'd,
sda: impl Peripheral<P = impl SdaPin<T>> + 'd,
irq: impl Peripheral<P = T::Interrupt> + 'd,
tx_dma: impl Peripheral<P = TXDMA> + 'd,
rx_dma: impl Peripheral<P = RXDMA> + 'd,
freq: Hertz
) -> Self
Where peri
expects an instance to an I2C peripheral, scl
and sda
expect instances of a GPIO pin, irq
expects a handle to an interrupt, tx_dma
and rx_dma
expect instances to a DMA channel, freq
expects I2C transaction frequency. What I cam to realize is that when using the shown method, I got a compile error that I wasn't including a Config
parameter. It turns out that the documentation in this case is outdated and the source code contains a slightly different signature:
pub fn new(
peri: impl Peripheral<P = T> + 'd,
scl: impl Peripheral<P = impl SclPin<T>> + 'd,
sda: impl Peripheral<P = impl SdaPin<T>> + 'd,
irq: impl Peripheral<P = T::Interrupt> + 'd,
tx_dma: impl Peripheral<P = TXDMA> + 'd,
rx_dma: impl Peripheral<P = RXDMA> + 'd,
freq: Hertz,
config: Config,
) -> Self
The difference includes a Config
type which includes an I2C configuration. As such, we can create a handle i2c
for I2C1 as follows:
let mut i2c = I2c::new(
p.I2C1,
p.PB8,
p.PB9,
irq,
NoDma,
NoDma,
hz(100000),
Default::default(),
);
Note how the different handles created earlier are passed as arguments as expected. Also the frequency is set to 100 kHz for operation, and NoDma
is passed for DMA channel. The BMP180 datasheet states that the device can handle up to 3.4Mbit/sec so I only chose an arbitrary value under the stated limit. Finally, for the Config
type, the default
instance is passed, similar to what has been done before. The implementation for Default
can be found in the same source code and looks as follows:
impl Default for Config {
fn default() -> Self {
Self {
sda_pullup: false,
scl_pullup: false,
}
}
}
Which from what can be seen is that Config
, configures the state of the I2C line pull-up resistors. That's it for I2C configuration, now moving on to UART.
UART Serial Communication Peripheral Configuration:
1οΈβ£ Configure UART and obtain handle: On the Nucleo-F401RE board pinout, the Tx line pin PA2 connects to the USART2 peripheral in the microcontroller device. Similar to what was done in the embassy UART post, an instance of USART2 is attached to the usart
handle as follows:
let mut usart = UartTx::new(p.USART2, p.PA2, NoDma, Config::default());
Also, similar to before a String
type msg
handle is created to store the formatted text that will be transmitted over UART:
let mut msg: String<64> = String::new();
Delay Configuration:
In the algorithm, a blocking delay will need to be introduced to wait for the ADC conversion in the BMP180 to finish. A delay
handle is created as follows as follows:
let mut delay = Delay;
This is it for configuration! Let's now jump into the application code.
π± Application Code
In the software design described, the first step requires that we read a bunch of calibration data from the EEPROM of BMP180. In order to that, I would need to set up and initialize some sort of a struct to save the calibration data. I called the struct type Coeffs
and defined it as follows:
struct Coeffs {
ac5: i16,
ac6: i16,
mc: i16,
md: i16,
}
After which I instantiate calib_coeffs
of type Coeffs
initialized to all zeros:
let mut calib_coeffs = Coeffs {
ac5: 0,
ac6: 0,
mc: 0,
md: 0,
};
π Note:
In the datasheet for the BMP180 there are 11 different calibration coefficients that need to be captured. Here I am capturing only the ones needed for temperature calculation.
Next, I define a bunch of constants that reflect the addresses for the calibration coefficients in the BMP180 EEPROM, the I2C address of the BMP180 itself (BMP180_ADDR
), and the address to retrieve the BMP180 device ID (REG_ID_ADDR
).
const BMP180_ADDR: u8 = 0x77;
const REG_ID_ADDR: u8 = 0xD0;
const AC5_MSB_ADDR: u8 = 0xB2;
const AC6_MSB_ADDR: u8 = 0xB4;
const MC_MSB_ADDR: u8 = 0xBC;
const MD_MSB_ADDR: u8 = 0xBE;
const CTRL_MEAS_ADDR: u8 = 0xF4;
const MEAS_OUT_LSB_ADDR: u8 = 0xF7;
const MEAS_OUT_MSB_ADDR: u8 = 0xF6;
I also define two variables, a [u8; 2]
array I named rx_buffer
and an i16
named rx_word
. I will be using rx_buffer
later to buffer data I read over I2C from the BMP180. rx_word
will be used to reconstruct the read bytes into a 16-bit value.
let mut rx_buffer: [u8; 2] = [0; 2];
let mut rx_word: i16;
Before doing anything, we have to understand how the BMP180 communicates over I2C. Essentially, the way the BMP180 communication works, first there is a write (control) cycle indicating what internal BMP180 EEPROM address we want to read. Second, there is a read cycle where the data in the address that was requested in the write cycle is provided.
The first thing I need to read according to the algorithmic steps is the calibration coefficients. The datasheet recommends, however, that before reading any calibration coefficients from the BMP180, the BMP180 device ID is read as a sanity check. The device should provide back the value of 0x55
. To do that, digging into the stm32 embassy I2C method documentation, I found a blocking_write
and blocking_read
method with the following signatures:
pub fn blocking_write(
&mut self,
address: u8,
bytes: &[u8]
) -> Result<(), Error>
and
pub fn blocking_read(
&mut self,
address: u8,
buffer: &mut [u8]
) -> Result<(), Error>
Although there wasn't much description in the documentation, it can be seen that both are more or less similar with a minor difference. Both take two arguments and return a Result
. The first argument address
is the device address in which case for us will be the BMP180 device address BMP180_ADDR
. The second argument for the blocking_write
method is bytes
and is an array slice containing the bytes being written. The second argument for the read
method is buffer
and is a mutable array slice containing the bytes being returned back by the addressed device.
There was also another method that caught my attention that looked useful. It was the blocking_write_read
method. Again, although there wasn't much description, it works similar to the write_read
method from the embedded-hal. The blocking_write_read
method does a write cycle immediately followed by a read cycle. This will be useful in many contexts and save some lines of code. The blocking_write_read
method has the following signature:
pub fn blocking_write_read(
&mut self,
address: u8,
bytes: &[u8],
buffer: &mut [u8]
) -> Result<(), Error>
The parameters are more or less the same as the earlier methods, only combined in one method.
So, now that I have what I need, to do our sanity check, the first step is reading from the BMP180 the device ID:
i2c.blocking_write(BMP180_ADDR, &[REG_ID_ADDR]).unwrap();
i2c.blocking_read(BMP180_ADDR, &mut rx_buffer).unwrap();
Analyzing the code, the first argument of the write and read methods is the device address BMP180_ADDR
, the second argument of the write method is a slice containing the device ID retrieval address REG_ID_ADDR
. In the read method, the second argument is the receive buffer rx_buffer
that will contain the recieved data. Note that all the arguments I am passing I have been created at an earlier point in the application. The below if
statement checks if the received ID is correct and sends the appropriate message accordingly over UART. Note that from the BMP180 datasheet, since REG_ID_ADDR
returns only a single byte, I only had to check the first index in the buffer rx_buffer[0]
if rx_buffer[0] == 0x55 {
core::writeln!(&mut msg, "Device ID is {}\r", rx_buffer[0]).unwrap();
usart.blocking_write(msg.as_bytes()).unwrap();
msg.clear();
} else {
core::writeln!(&mut msg, "Device ID Cannot be Detected \r").unwrap();
usart.blocking_write(msg.as_bytes()).unwrap();
msg.clear();
}
Now that its verified that the device can be detected, let's start with the first step in the algorithmic steps which is collecting the calibration coefficients. Here's the code for retrieving calibration coefficient AC5:
i2c.blocking_write_read(BMP180_ADDR, &[AC5_MSB_ADDR], &mut rx_buffer)
.unwrap();
rx_word = ((rx_buffer[0] as i16) << 8) | rx_buffer[1] as i16;
core::writeln!(&mut msg, "AC5 = {} \r", rx_word).unwrap();
usart.blocking_write(msg.as_bytes()).unwrap();
msg.clear();
calib_coeffs.ac5 = rx_word;
As might be noticed I used the blocking_write_read
method exactly in a simiar manner that I did earlier. There are three differences to note here though:
Recall I mentioned that the BMP180 provides a 16-bit value for each calibration coefficient. Since I2C communicates in bytes, each coefficient is broken down into two bytes MSB and LSB, with each having its own address in the BMP180 EEPROM. This means that technically I would need to retrieve each address separately and reconstruct the 16-bit value. Though it turns out that by sending only the MSB address to the BMP180 (
AC5_MSB_ADDR
in this case) the device sends back both the MSB followed by the LSB without needing to address the LSB separately. The MSB will be located in the first index of the buffer and the LSB in the second index. So the point here is that I needed to call theread_write
method only once usingAC5_MSB_ADDR
to retrieve bothAC5_MSB_ADDR
andAC5_LSB_ADDR
.Since an
i16
will be provided back, the linerx_word = ((rx_buffer[0] as i16) << 8) | rx_buffer[1] as i16;
takes the MSB bits fromrx_buffer[0]
, casts them to ani16
and shifts the 1 byte (8 times) to the left using the<<
operator, then ORs the result using the|
operator with the LSB bits inrx_buffer[1]
that are also cast asi16
. The result is finally stored inrx_word
and then sent over the UART channel.The
rx_word
is stored in thecalib_coeffs
struct I had created earlier in the statementcalib_coeffs.ac5 = rx_word;
. This would also allow me to reuse rx_word for the following operations.
This coefficient obtaining code is repeated in the exact same manner three times to retrieve the AC6, MC, and MD coefficients. Obviously, the only differences would be the address sent to the BMP180, and the name of the member I store in the calib_coeffs
struct.
Now that the calibration coefficients are available, the measurement loop can be started. The first step as indicated by the software design is to kick-off the temperature measurement in the BMP180 by writing 0x2E in register with address 0xF4 ( keyed in as CTRL_MEAS_ADDR
). This is done using two write cycles first sending 0x2E
followed by CTRL_MEAS_ADDR
. This can be done in a single line as well. Since the blocking_write
method accepts a slice in the bytes
parameter in its signature, all the bytes that need to be sent can be included in the slice as follows:
i2c.blocking_write(BMP180_ADDR, &[CTRL_MEAS_ADDR, 0x2E]).unwrap();
This statement will send the CTRL_MEAS_ADDR
to the BMP180 followed by the value 0x2E
. For those familiar with I2C terms, although I didn't verify at the low level, nor does the documentation specify, but here a "repeated start" should be what is occurring.
After kicking off the BMP180 temperature measurement, the datasheet tells us we need to wait at least 4.5ms. As such, a 5ms is introduced to stay on the safe side. This is done using the delay
handle created earlier:
delay.delay_ms(5_u32);
Now the temperature measurement should be ready to collect by reading the measurement BMP180 MSB and LSB EEPROM addresses. This can be done using the similar approach to before where only the MSB address is sent. Alternatively, here a different approach is adopted by reading the MSB and the LSB separately:
// Read Measurement MSB
i2c.blocking_write(BMP180_ADDR, &[MEAS_OUT_MSB_ADDR]).unwrap();
i2c.blocking_read(BMP180_ADDR, &mut rx_buffer).unwrap();
rx_word = (rx_buffer[0] as i16) << 8;
// Read Measurement LSB
i2c.blocking_write(BMP180_ADDR, &[MEAS_OUT_LSB_ADDR]).unwrap();
i2c.blocking_read(BMP180_ADDR, &mut rx_buffer).unwrap();
rx_word |= rx_buffer[0] as i16;
Finally, since the temperature value received is uncompensated. The datasheet provides us with a bunch of formulas to calculate the compensated temperature based on the calibration coefficients that were collected earlier. The following code implements the formulas in the datasheet to calculate the temperature followed by sending the result over UART:
let x1 = (rx_word as i32 - calib_coeffs.ac6 as i32) * (calib_coeffs.ac5 as i32) >> 15;
let x2 = ((calib_coeffs.mc as i32) << 11) / (x1 + calib_coeffs.md as i32);
let b5 = x1 + x2;
let t = ((b5 + 8) >> 4) / 10;
// Print Temperature Value
core::writeln!(&mut msg, "Temperature = {:} \r", t).unwrap();
usart.blocking_write(msg.as_bytes()).unwrap();
msg.clear();
Note how values are cast as i32
so to prevent overflow from the multiplication operations.
This is it!
π Full Application Code
Here is the full code for the implementation described in this post. You can additionally find the full project and others available on the apollolabsdev Nucleo-F401RE git repo.
#![no_std]
#![no_main]
#![feature(type_alias_impl_trait)]
use core::fmt::Write;
use cortex_m::prelude::_embedded_hal_blocking_delay_DelayMs;
use heapless::String;
use cortex_m_rt::entry;
use embassy_stm32::dma::NoDma;
use embassy_stm32::i2c::I2c;
use embassy_stm32::interrupt;
use embassy_stm32::time::hz;
use embassy_stm32::usart::{Config, UartTx};
use embassy_time::Delay;
use panic_halt as _;
#[entry]
fn main() -> ! {
// Initialize and create handle for devicer peripherals
let p = embassy_stm32::init(Default::default());
let irq = interrupt::take!(I2C1_EV);
// I2C Configuration
let mut i2c = I2c::new(
p.I2C1,
p.PB8,
p.PB9,
irq,
NoDma,
NoDma,
hz(100000),
Default::default(),
);
//Configure UART
let mut usart = UartTx::new(p.USART2, p.PA2, NoDma, Config::default());
// Create empty String for message
let mut msg: String<64> = String::new();
// Delay Handle
let mut delay = Delay;
struct Coeffs {
ac5: i16,
ac6: i16,
mc: i16,
md: i16,
}
let mut calib_coeffs = Coeffs {
ac5: 0,
ac6: 0,
mc: 0,
md: 0,
};
const BMP180_ADDR: u8 = 0x77;
const REG_ID_ADDR: u8 = 0xD0;
const AC5_MSB_ADDR: u8 = 0xB2;
const AC6_MSB_ADDR: u8 = 0xB4;
const MC_MSB_ADDR: u8 = 0xBC;
const MD_MSB_ADDR: u8 = 0xBE;
const CTRL_MEAS_ADDR: u8 = 0xF4;
const MEAS_OUT_LSB_ADDR: u8 = 0xF7;
const MEAS_OUT_MSB_ADDR: u8 = 0xF6;
let mut rx_buffer: [u8; 2] = [0; 2];
let mut rx_word: i16;
// Read Device ID as Sanity Check
i2c.blocking_write(BMP180_ADDR, &[REG_ID_ADDR]).unwrap();
i2c.blocking_read(BMP180_ADDR, &mut rx_buffer).unwrap();
if rx_buffer[0] == 0x55 {
core::writeln!(&mut msg, "Device ID is {}\r", rx_buffer[0]).unwrap();
// Transmit Message
usart.blocking_write(msg.as_bytes()).unwrap();
// Clear String for next message
msg.clear();
} else {
core::writeln!(&mut msg, "Device ID Cannot be Detected \r").unwrap();
usart.blocking_write(msg.as_bytes()).unwrap();
msg.clear();
}
// Read Calibration Coefficients
// Read AC5
i2c.blocking_write_read(BMP180_ADDR, &[AC5_MSB_ADDR], &mut rx_buffer)
.unwrap();
rx_word = ((rx_buffer[0] as i16) << 8) | rx_buffer[1] as i16;
core::writeln!(&mut msg, "AC5 = {} \r", rx_word).unwrap();
usart.blocking_write(msg.as_bytes()).unwrap();
msg.clear();
calib_coeffs.ac5 = rx_word;
// Read AC6
i2c.blocking_write_read(BMP180_ADDR, &[AC6_MSB_ADDR], &mut rx_buffer)
.unwrap();
rx_word = ((rx_buffer[0] as i16) << 8) | rx_buffer[1] as i16;
core::writeln!(&mut msg, "AC6 = {} \r", rx_word).unwrap();
usart.blocking_write(msg.as_bytes()).unwrap();
msg.clear();
calib_coeffs.ac6 = rx_word;
// Read MC
i2c.blocking_write_read(BMP180_ADDR, &[MC_MSB_ADDR], &mut rx_buffer)
.unwrap();
rx_word = ((rx_buffer[0] as i16) << 8) | rx_buffer[1] as i16;
core::writeln!(&mut msg, "MC = {} \r", rx_word).unwrap();
usart.blocking_write(msg.as_bytes()).unwrap();
msg.clear();
calib_coeffs.mc = rx_word;
// Read MD
i2c.blocking_write_read(BMP180_ADDR, &[MD_MSB_ADDR], &mut rx_buffer)
.unwrap();
rx_word = ((rx_buffer[0] as i16) << 8) | rx_buffer[1] as i16;
core::writeln!(&mut msg, "MD = {} \r", rx_word).unwrap();
usart.blocking_write(msg.as_bytes()).unwrap();
msg.clear();
calib_coeffs.md = rx_word;
// Application Loop
loop {
// Kick off Temperature Measurement by writing 0x2E in register 0xF4
i2c.blocking_write(BMP180_ADDR, &[CTRL_MEAS_ADDR, 0x2E])
.unwrap();
// Wait at least 4.5 ms for measurment to complete as specified by the datasheet
delay.delay_ms(5_u32);
// Collect Temperature Measurment
// Read Measurement MSB
// Achieving same as above using an alternate method syntax here to do a write followed by read
i2c.blocking_write(BMP180_ADDR, &[MEAS_OUT_MSB_ADDR])
.unwrap();
i2c.blocking_read(BMP180_ADDR, &mut rx_buffer).unwrap();
rx_word = (rx_buffer[0] as i16) << 8;
// Read Measurement LSB
i2c.blocking_write(BMP180_ADDR, &[MEAS_OUT_LSB_ADDR])
.unwrap();
i2c.blocking_read(BMP180_ADDR, &mut rx_buffer).unwrap();
rx_word |= rx_buffer[0] as i16;
// Uncomment following line to print raw uncompenstated temperature value
//writeln!(tx, "UT = {} \r", rx_word).unwrap();
// Calculate Temperature According to Datasheet Formulas
let x1 = (rx_word as i32 - calib_coeffs.ac6 as i32) * (calib_coeffs.ac5 as i32) >> 15;
let x2 = ((calib_coeffs.mc as i32) << 11) / (x1 + calib_coeffs.md as i32);
let b5 = x1 + x2;
let t = ((b5 + 8) >> 4) / 10;
// Print Temperature Value
core::writeln!(&mut msg, "Temperature = {:} \r", t).unwrap();
usart.blocking_write(msg.as_bytes()).unwrap();
msg.clear();
}
}
π¬ Further Experimentation/Ideas:
Some ideas to experiment with include:
Expanding the code to collect pressure measurement data including calibration data and calculate barometric pressure.
The BMP180 has different accuracy modes in which more accurate measurements can be provided but also different wait times apply as well. You can refactor the code or even create functions that can select one of the desired modes.
Conclusion
In this post, an I2C temperature measurement application was created STM32 using the embassy framework HAL by controlling and collecting data from an external BMP180 sensor. This application leverages an I2C peripheral for the STM32F401RE microcontroller on the Nucleo-F401RE development board. The resulting measurement is also sent over to a host PC over a UART connection. All code was based on polling (without interrupts). Have any questions? Share your thoughts in the comments below π.