In this post, we explore the end-to-end development of an embedded environmental monitoring dashboard running on the STM32F769I-DISCO development kit (ARM Cortex-M7 @ 216 MHz with Chrom-ART DMA2D hardware acceleration).
By combining FreeRTOS, the Bosch BME280 sensor, and the LVGL v9.5 graphics engine utilizing LVGL Pro XML declarative UI components, we built a reactive, decoupled, and visually stunning user interface with dynamic comfort analysis and smooth animations.
![]() |
| Demo |
Project Highlights & Architecture
- MCU & Board: STM32F769I-DISCO (ARM Cortex-M7 @ 216 MHz, Chrom-ART DMA2D acceleration, 800×480 DSI Capacitive Touchscreen).
- RTOS: FreeRTOS Kernel with thread-safe inter-task messaging and dedicated GUI/Sensor tasks.
- Sensor: Bosch BME280 connected via I2C (configured for high-efficiency 32-bit fixed-point arithmetic).
- Graphics: LVGL v9.5 configured for 16-bit RGB565 color depth.
- Declarative UI: Designed with LVGL Pro XML, utilizing reusable XML components (<sensor_card>), reactive data binding (lv_subject_t), and smooth screen fade transitions.
- Smart Evaluation Engine: Clean, table-driven comfort and weather classification that dynamically updates status labels, border highlights, and glowing shadow effects.
Hardware Setup & Peripheral Assignment
The STM32F769I-DISCO features multiple I2C buses. It is critical to select the correct bus to avoid peripheral conflicts:
- I2C4 (PD12/PB7): Reserved by the board for the on-board Capacitive Touchscreen controller (FT6206) and Audio Codec (WM8994).
- I2C1 (PB8/PB9): Routed to the Arduino Uno V3 expansion connector D15 (SCL) and D14 (SDA).
Sensor Wiring
| BME280 Pin | STM32F769I-DISCO Arduino Header | STM32 Pin |
|---|---|---|
| VCC | 3.3V | 3.3V |
| GND | GND | GND |
| SCL | D15 | PB8 (I2C1_SCL) |
| SDA | D14 | PB9 (I2C1_SDA) |
Autonomous BME280 Sensor Acquisition via FreeRTOS
- Dedicated Sensor Task: A background FreeRTOS task autonomously communicates with the Bosch BME280 sensor over hardware I2C1 (PB8/PB9 on the Arduino header) at 1 Hz.
- Zero Floating-Point Overhead: Configured with BME280_32BIT_ENABLE, the driver performs all sensor compensation using fast 32-bit fixed-point integer math, bypassing expensive software floating-point calculations.
- Thread-Safe Event Queue: Sensor readings are packaged into a structured message (gui_mng_event_data_t) and posted to the GUI Manager Task via a FreeRTOS message queue (GUI_MNG_EV_SENSOR_DATA_UPDATE), ensuring that UI rendering and sensor I2C communication never block or corrupt each other.
Lightweight 32-Bit Fixed-Point BME280 Driver
To avoid heavy software floating-point or double precision arithmetic overhead on embedded microcontrollers, we compiled the Bosch Sensortec driver with BME280_32BIT_ENABLE.
Driver Configuration
- Temperature: Reported in 0.01 °C (e.g., 2150 = 21.50 °C).
- Humidity: Reported in 1/1024 %RH (e.g., 59392 >> 10 = 58 %RH).
- Pressure: Reported in integer Pascals (e.g., 101325 / 100 = 1013 hPa).
FreeRTOS Safe Sampling Task
/**
* @brief bme280 sensor task to read data periodically
* @param pvParameters Pointer to the task parameters
*/
static void bme280_task( void *pvParameters )
{
(void)pvParameters;
int8_t init_status = BME280_E_DEV_NOT_FOUND;
/* Retry initialization until sensor responds */
while ( init_status != BME280_OK )
{
init_status = bme280_sensor_init();
if ( init_status != BME280_OK )
{
/* Delay for 1 second before retrying */
vTaskDelay( pdMS_TO_TICKS(1000) );
}
}
for (;;)
{
/* Read compensated data (Temperature, Pressure, Humidity) */
if ( bme280_get_sensor_data( BME280_ALL, &sensor_data, &dev) == BME280_OK )
{
/* Prepare GUI event message */
gui_mng_event_data_t ev_data;
ev_data.sensor_data.temperature = sensor_data.temperature;
ev_data.sensor_data.humidity = sensor_data.humidity;
ev_data.sensor_data.pressure = sensor_data.pressure;
/* Send non-blocking event to GUI task */
gui_send_event( GUI_MNG_EV_SENSOR_DATA_UPDATE, &ev_data );
}
/* Delay for 1 second before next reading */
vTaskDelay( pdMS_TO_TICKS(1000) );
}
}
Declarative UI Design with LVGL Pro XML
Instead of writing hundreds of lines of imperative C code to position labels and boxes, the entire interface was designed declaratively using LVGL XML.
Reusable Component: sensor_card.xml
We created a single modular component that encapsulates the glowing glass morphic card, big value label, center image graphic, dynamic status text, and bottom title:
<component>
<api>
<prop name="value_text" type="subject" />
<prop name="img_src" type="image" default="thermometer_img" />
<prop name="status_label_text" type="subject" default="Status" />
<prop name="status_text_color" type="color" default="0x8FA0B8" />
<prop name="card_title_text" type="string" default="Card Title" />
<prop name="theme_color" type="color" default="0xF39C12" />
</api>
<view
extends="lv_obj"
width="31%"
height="content"
style_bg_opa="0"
style_border_width="0"
style_pad_all="0"
flex_flow="column"
style_flex_cross_place="center"
style_pad_row="10"
>
<!-- 1. Glowing Card Box -->
<lv_obj
name="card_box"
width="100%"
height="310"
scrollable="false"
style_bg_color="0x1D2939"
style_bg_opa="255"
style_radius="20"
style_border_width="2"
style_border_color="$theme_color"
style_shadow_width="20"
style_shadow_color="$theme_color"
style_shadow_opa="30"
style_pad_all="18"
flex_flow="column"
style_flex_main_place="space_between"
style_flex_cross_place="center"
>
<!-- Top Value Lavel -->
<lv_label
name="value_label"
width="100%"
bind_text="$value_text"
style_text_font="title_large"
style_text_color="0xFAFAFA"
style_text_align="center"
/>
<!-- Middle: Center Image Graphic -->
<lv_image name="center_img" src="$img_src" />
<!-- Status Sub Text -->
<lv_label
name="status_label"
width="100%"
bind_text="$status_label_text"
style_text_font="body_normal"
style_text_color="$status_text_color"
style_text_align="center"
/>
</lv_obj>
<!-- Sensor Card Name -->
<lv_label
name="title_label"
width="100%"
text="$card_title_text"
style_text_color="0xCAD5E2"
style_text_font="body_normal"
style_text_align="center"
/>
</view>
</component>
Table-Driven Classification & Dynamic Styling
Instead of messy nested if-else blocks, we used clean lookup tables that map numerical sensor ranges to human comfort levels and visual colors.
/* Threshold Rule Structure */
typedef struct
{
int32_t max_threshold; /* Range Upper Limit */
const char *text; /* status text */
uint32_t color; /* status color */
} sensor_range_rule_t;
/* Temperature Rules (in °C) */
static const sensor_range_rule_t temp_rules[] =
{
{ 10, "Too Cold", 0x3498DB }, /* Blue */
{ 18, "Cold", 0x5DADE2 }, /* Light Blue */
{ 26, "Ideal", 0x2ECC71 }, /* Emerald Green */
{ 32, "Warm", 0xF39C12 }, /* Orange */
{ 100, "Hot", 0xE74C3C } /* Red */
};
/* Humidity Rules (in %RH) */
static const sensor_range_rule_t hum_rules[] =
{
{ 30, "Too Dry", 0xE67E22 }, /* Amber */
{ 40, "Dry", 0xF1C40F }, /* Yellow */
{ 60, "Ideal", 0x00E5FF }, /* Glowing Cyan */
{ 70, "Humid", 0x3498DB }, /* Blue */
{ 100, "Too Humid", 0x9B59B6 } /* Purple */
};
/* Barometric Pressure Rules (in hPa) */
static const sensor_range_rule_t press_rules[] =
{
{ 1000, "Low (Rain)", 0xE74C3C }, /* Red (Rain/Stormy) */
{ 1020, "Stable", 0xBD10E0 }, /* Purple (Fair/Calm) */
{ 1200, "High (Clear)", 0x2ECC71 } /* Green (Sunny) */
};
Event Handler & Dynamic Color Reflection
Whenever new sensor data arrives, strings are formatted, subjects are updated, and the card's border/shadow glow dynamically shifts according to the environmental comfort level:
static void gui_sensor_data_update( const gui_mng_event_data_t *data )
{
if ( (active_sensor_screen == NULL) || (data == NULL) )
{
return; /* No active sensor screen or no data to update */
}
char buf[32];
/* Temperature: Value & Status */
int32_t t_int = data->sensor_data.temperature / 100;
int32_t t_dec = (data->sensor_data.temperature % 100) / 10;
snprintf( buf, sizeof(buf), "%ld.%ld °C", t_int, (t_dec < 0 ? -t_dec : t_dec) );
lv_subject_copy_string( &temp_str, buf );
const sensor_range_rule_t *t_rule = get_sensor_rule( t_int, temp_rules, NUM_ELEMENTS(temp_rules) );
lv_subject_copy_string( &temp_status_str, t_rule->text );
/* Humidity: Value & Status */
uint32_t hum_pct = data->sensor_data.humidity >> 10;
snprintf( buf, sizeof(buf), "%lu %%", hum_pct );
lv_subject_copy_string( &hum_str, buf );
const sensor_range_rule_t *h_rule = get_sensor_rule( (int32_t)hum_pct, hum_rules, NUM_ELEMENTS(hum_rules) );
lv_subject_copy_string( &hum_status_str, h_rule->text );
/* Pressure: Value & Status */
uint32_t press_hpa = data->sensor_data.pressure / 100;
snprintf( buf, sizeof(buf), "%lu\nhPa", press_hpa );
lv_subject_copy_string( &press_str, buf );
const sensor_range_rule_t *p_rule = get_sensor_rule( (int32_t)press_hpa, press_rules, NUM_ELEMENTS(press_rules) );
lv_subject_copy_string( &press_status_str, p_rule->text );
/* Updating Status Label Colors and Box Color Logic */
/* Cards Row Container */
lv_obj_t *cards_row = lv_obj_get_child( active_sensor_screen, 1 );
if ( cards_row != NULL )
{
/* temperature status coloring logic */
lv_obj_t *temp_card = lv_obj_get_child( cards_row, 0 );
if ( temp_card )
{
lv_obj_t *box = lv_obj_get_child_by_name( temp_card, "card_box" );
lv_obj_t *lbl = lv_obj_get_child_by_name( box, "status_label" );
if ( lbl )
{
lv_obj_set_style_text_color( lbl, lv_color_hex( t_rule->color ), LV_PART_MAIN );
}
if ( box )
{
lv_obj_set_style_border_color( box, lv_color_hex( t_rule->color ), LV_PART_MAIN );
lv_obj_set_style_shadow_color( box, lv_color_hex( t_rule->color ), LV_PART_MAIN );
}
}
/* humidity status coloring logic */
lv_obj_t *hum_card = lv_obj_get_child( cards_row, 1 );
if ( hum_card )
{
lv_obj_t *box = lv_obj_get_child_by_name( hum_card, "card_box" );
lv_obj_t *lbl = lv_obj_get_child_by_name( box, "status_label" );
if ( lbl )
{
lv_obj_set_style_text_color( lbl, lv_color_hex( h_rule->color ), LV_PART_MAIN );
}
if ( box )
{
lv_obj_set_style_border_color( box, lv_color_hex( h_rule->color ), LV_PART_MAIN );
lv_obj_set_style_shadow_color( box, lv_color_hex( h_rule->color ), LV_PART_MAIN );
}
}
/* pressure status coloring logic */
lv_obj_t *press_card = lv_obj_get_child( cards_row, 2 );
if ( press_card )
{
lv_obj_t *box = lv_obj_get_child_by_name( press_card, "card_box" );
lv_obj_t *lbl = lv_obj_get_child_by_name( box, "status_label" );
if ( lbl )
{
lv_obj_set_style_text_color( lbl, lv_color_hex( p_rule->color ), LV_PART_MAIN );
}
if ( box )
{
lv_obj_set_style_border_color( box, lv_color_hex( p_rule->color ), LV_PART_MAIN );
lv_obj_set_style_shadow_color( box, lv_color_hex( p_rule->color ), LV_PART_MAIN );
}
}
}
}
Key Takeaways & Embedded Best Practices
- Fixed-Point Arithmetic: Enabling BME280_32BIT_ENABLE cut execution latency and code size significantly compared to double float libraries.
- RGB565 vs ARGB8888: Running LVGL with 16-bit RGB565 reduced SDRAM bandwidth and halved framebuffer RAM usage, yielding smooth performance.
- Declarative Component Architecture: Creating reusable <sensor_card> XML components eliminated repetitive UI code and guaranteed 100% pixel-perfect layout symmetry across all cards.
- Reactive Subjects (lv_subject_t): Decoupled the FreeRTOS event-processing logic from the UI widget tree. Updating the UI requires only updating data subjects.


No comments:
Post a Comment