Reference / Case Study

STM32 SCD41 CO2 Sensor Indoor Air Quality Case Study: MQTT and I2C Integration

Author: Thomas Bonderup Published: Engagement type: Indoor Climate Field Proof

Engineering case study of integrating an SCD41 CO2 sensor with an STM32 B-L475E-IOT01A2 over I2C, publishing CO2, temperature, and humidity through MQTT with live room-state visibility and configurable CO2 alarms.

Why this reference matters

Real evidence, not abstract claims

These references show the kind of architecture, delivery pressure, and proof Combotto works with in practice.

Findings should lead somewhere

A strong audit produces a concrete backlog, not a vague list of concerns that dies after the meeting.

Implementation follows the evidence

The Sprint is where the highest-value fixes get done and verified before posture drifts again.

Indoor Climate Field Proof stm32 indoor air quality monitoring co2 monitoring scd41
SCD41 member of the SCD41 CO2 sensor family used in the STM32 indoor air quality integration
Hero image: the SCD41 sensor module used for the STM32 indoor air quality integration, highlighting the SCD41 CO2 sensor hardware behind the case study.

Overview

This case study documents a small indoor-climate monitoring system built around an IoT device using a STM32 B-L475E-IOT01A2 Arm® Cortex®, an external SCD41 CO2 sensor, MQTT transport, and an internal gateway and monitoring system.

The technical goal was straightforward: take a IoT board that already worked for basic room telemetry, add a CO2 sensor over I2C, preserve a modular firmware structure, and make sure the resulting signal stayed useful once it reached the monitoring layer.

The result is a compact edge-to-cloud path that can be inspected at each layer.

Problem context

Temperature and humidity alone can show that a room is drifting, but they do not explain whether the room is becoming unhealthy under occupancy or whether ventilation response is actually needed. For indoor-climate use cases, CO2 changes that interpretation.

The practical problem behind this work was not just adding CO2 as another sensor value. It was making the measurement useful across the full device-to-dashboard path:

  • adding CO2 without tangling the embedded device code
  • keeping the telemetry format understandable outside the board firmware
  • surfacing readings and alerts in a way that helps diagnose real room behavior
  • distinguishing real environmental drift from stale sensor data, weak thresholds, or pipeline issues

This is where many room-monitoring prototypes stop being operationally useful. They can produce values, but the readings often lack enough context to support decisions. A useful system needs to preserve the meaning of the measurement as it moves from the physical sensor, through firmware and transport, into storage, dashboards, and alerts.

System under test

The system in scope is intentionally small:

  • STM32 B-L475E-IOT01A2 room-health IoT device
  • SCD41 CO2 sensor connected over I2C
  • room-health readings published to MQTT
  • Rust-based IoT Gateway carrying the message path
  • Monitoring system evaluating room state and CO2 alarms configured through configuration files

The value of keeping the slice this small is that each part stays inspectable. It is possible to reason about the sensor integration, the publish path, and the monitor behavior without hiding behind embedded system complexity.

Device layer

STM32 + SCD41

The STM32 room node reads CO2 plus more precise temperature and humidity through an SCD41 sensor abstraction without changing the rest of the publish path.

Gateway layer

MQTT path stays simple

The same Rust IoT Gateway and MQTT path can carry the indoor-climate payload without sensor-specific branching in the transport layer.

Operator layer

Indoor health alarms

Monitoring system can now evaluate CO2 thresholds from configuration and turn the raw readings into visible indoor-air-quality alarms and follow-up actions.

Current room-state view

The initial monitoring view shows the current room-health state for one device. That matters because room telemetry is only useful when the latest reading, freshness, and recent trend can be seen together.

In this setup, the room-state panel carries:

  • current temperature, humidity, and CO2
  • freshness status for the latest sample
  • one-hour, 24-hour, and 7-day traces for fast inspection
  • a health band that tells the operator whether the room is inside the intended range
  • room identity preserved from device to monitor

Room-health panel showing current temperature, humidity, and CO2 values plus 1H, 24H, and 7D traces

Image description: The room-health view shows one STM32 device reporting fresh temperature, humidity, and CO2 data, with short and long traces available in the same operational panel.

For an engineer, the useful part is not that the dashboard says “Healthy.” The useful part is that one dashboard panel combines current values with recent history. That makes it easier to tell whether the room is actually stable, whether CO2 is rising because people are present, or whether the signal quality itself needs investigation.

This is also where the extra sensor dimension matters. Temperature and humidity trends are still visible, but CO2 gives the system a better indoor-air-quality signal than a temperature-only interpretation.

Telemetry history, trust, and corrected temperature

The telemetry-history dashboard view separates three questions that otherwise get mixed together:

  • is the device still publishing fresh samples?
  • what did the CO2 trend actually do during the selected window?
  • how should temperature be interpreted when the board sits in a printed enclosure?

Device telemetry history dashboard showing active device count, peak CO2, sample cadence, silent devices, corrected temperature, and a temperature correction note

Image description: The first telemetry-history screenshot shows the trust layer around the room-health stream: one fresh device, two silent devices from the wider fleet, 347 samples in range, peak CO2 at 991 ppm, no current CO2 alert candidates, and corrected temperature displayed beside the freshness and sample-coverage panels.

The corrected-temperature panel is included because this device is now running inside a 3D printed enclosure. The enclosure makes the device easier to handle and place, but it also changes how raw board temperature should be interpreted. In this run, the monitor keeps the raw STM32 temperature, the SCD41 temperature, and a corrected temperature side by side so the adjustment stays auditable instead of becoming a hidden dashboard tweak.

Device telemetry history dashboard showing humidity, CO2 over time, CO2 threshold bands, peak CO2 windows, latest environmental readings, and data coverage

Image description: The second telemetry-history screenshot shows the operational signal behind the summary: humidity and CO2 trends, green/yellow/orange/red CO2 bands, peak CO2 windows, latest environmental readings with raw, corrected, and SCD41 temperature values, and 100% coverage for temperature, humidity, and CO2 samples during the selected period.

For this case study, the important point is not that the correction is final calibration. It is that the system now preserves enough context to compare readings and tune the interpretation over time. That is the difference between a value being merely visible and a value being useful as engineering evidence.

Alert history and failure modes

The alert history view is where the setup becomes easier for operators to debug and fine tune the system.

Instead of showing only the latest sensor values in a dashboard, the monitoring systems records explicit events with timestamped context. In the current setup, the event list captures several different classes of room-health behavior:

  • recovered high CO2 alerts with ppm context
  • recovered high-temperature warnings with Celsius context
  • stale-sensor warnings when the freshness story degrades
  • timestamped evidence that can support incident review, threshold tuning, and rule refinement

Alert history showing CO2 high, temperature high, and environment sensor stale events

Image description: The updated alert history shows that the same room-health path can surface CO2, temperature, and freshness events as explicit monitor evidence instead of leaving the operator to infer them from raw values.

This matters because a bad indoor-climate reading can have several different causes:

  • the CO2 level may actually be rising because people are in the room
  • temperature or humidity may show that the room is changing over time
  • the sensor may have stopped sending fresh data
  • the alert threshold may simply be too sensitive or too weak

The alert history helps separate these cases. Instead of only seeing a number on a dashboard, the operator can see what happened, when it happened, and whether the system later recovered.

How the STM32 SCD4x integration was done

The device is built around STM32 B-L475E-IOT01A2. The main firmware change was adding an external SCD41 CO2 sensor on the device side while keeping the integration modular. The module shown here is the SCD41, which sits inside the broader SCD4x sensor family.

STM32 B-L475E-IOT01A2 board wired to an SCD4x CO2 sensor module for the indoor climate device slice

Image description: The integrated device photo shows the STM32 B-L475E-IOT01A2 board physically wired to the SCD41 CO2 sensor module. It provides the board-level context behind the room-health readings shown in the monitor screenshots.

At a high level, the integration looks like this:

  • the SCD41 module is connected to the STM32 over I2C
  • a modular sensor abstraction reads the sensor and normalizes the sample into room-health fields
  • the device publish layer reuses that normalized sample when it sends CO2, temperature, and humidity to MQTT
  • the downstream gateway and monitor can keep working with the same transport and asset model

That split keeps the responsibilities clear:

  • firmware owns sensor access and payload formation
  • the gateway owns message transport
  • the monitor owns threshold logic and alert presentation

The practical benefit is that the device can evolve from onboard humidity sensing into a fuller indoor-air-quality node without rewriting the rest of the telemetry chain.

The firmware integration keeps the add-on sensor optional. That was a deliberate design choice, not just defensive coding. The STM32 board already had a working baseline telemetry loop, so the CO2 work needed to extend that loop without making the whole device depend on one external module being connected, warmed up, and ready at every publish interval.

The project treats the SCD41 as a small sensor wrapper around the lower-level I2C driver, then lets the telemetry builder decide whether a fresh sample is available. If the SCD41 path is not ready, the payload still carries the base board readings and marks the SCD41-derived fields as null. If the read succeeds, the same payload shape gains co2, scd41_temperature, and scd41_humidity:

SCD4X_Measurement_t sample = {0U, 0.0f, 0.0f};
char co2_text[16];
char scd41_temperature_text[24];
char scd41_humidity_text[24];

if (read_scd41_measurement(&sample) == SENSOR_OK)
{
  snprintf(co2_text, sizeof(co2_text), "%u", sample.co2_ppm);
  snprintf(scd41_temperature_text, sizeof(scd41_temperature_text), "%.2f", sample.temperature_c);
  snprintf(scd41_humidity_text, sizeof(scd41_humidity_text), "%.2f", sample.humidity_percent);
}
else
{
  snprintf(co2_text, sizeof(co2_text), "null");
  snprintf(scd41_temperature_text, sizeof(scd41_temperature_text), "null");
  snprintf(scd41_humidity_text, sizeof(scd41_humidity_text), "null");
}

The important shape is the boundary, not the exact function names: I2C sensor work stays on the STM32 side, the published telemetry remains predictable, and the monitor can compare the onboard temperature, SCD41 temperature, corrected temperature, humidity, and CO2 without special-casing transport.

The lower-level setup is still worth showing because this is where hardware integration becomes concrete. The external sensor uses a dedicated I2C1 path on the STM32 header instead of being folded into the rest of the telemetry code. In practical terms:

  • PB8 is used as I2C1 SCL
  • PB9 is used as I2C1 SDA
  • the GPIO port clock is enabled before the pins are configured
  • both pins are configured as alternate-function, open-drain GPIO for I2C1
  • the I2C1 peripheral clock is enabled and reset before HAL_I2C_Init
  • the SCD4x driver receives a small bus-IO table with init, read, write, tick, and delay callbacks
#define ROOM_CO2_I2C_INSTANCE I2C1
#define ROOM_CO2_SCL_PIN      GPIO_PIN_8
#define ROOM_CO2_SDA_PIN      GPIO_PIN_9
#define ROOM_CO2_GPIO_PORT    GPIOB
#define ROOM_CO2_GPIO_AF      GPIO_AF4_I2C1

static I2C_HandleTypeDef room_co2_i2c;

static int32_t room_co2_i2c_init(void)
{
  GPIO_InitTypeDef gpio = {0};

  __HAL_RCC_GPIOB_CLK_ENABLE();
  __HAL_RCC_I2C1_CLK_ENABLE();

  gpio.Pin = ROOM_CO2_SCL_PIN | ROOM_CO2_SDA_PIN;
  gpio.Mode = GPIO_MODE_AF_OD;
  gpio.Pull = GPIO_NOPULL;
  gpio.Speed = GPIO_SPEED_FREQ_HIGH;
  gpio.Alternate = ROOM_CO2_GPIO_AF;
  HAL_GPIO_Init(ROOM_CO2_GPIO_PORT, &gpio);

  __HAL_RCC_I2C1_FORCE_RESET();
  __HAL_RCC_I2C1_RELEASE_RESET();

  room_co2_i2c.Instance = ROOM_CO2_I2C_INSTANCE;
  room_co2_i2c.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
  room_co2_i2c.Init.Timing = room_co2_i2c_timing_100khz();

  return HAL_I2C_Init(&room_co2_i2c) == HAL_OK ? SENSOR_OK : SENSOR_BUS_ERROR;
}

The SCD4x-specific code then sits one layer above that bus setup. It does not need to know about the dashboard, MQTT topic, or alert thresholds. It only needs a way to initialize the bus, send commands, read bytes, and wait between sensor operations:

SCD4X_IO_t bus = {
  .Init = room_co2_i2c_init,
  .Write = room_co2_i2c_write,
  .Read = room_co2_i2c_read,
  .GetTick = board_get_tick,
  .Delay = board_delay_ms,
};

register_scd4x_bus(&room_co2_sensor, &bus);
start_periodic_measurement(&room_co2_sensor);

That layering is what keeps the project understandable: pin setup and I2C timing live in the board integration layer, SCD41 commands live in the sensor wrapper, and the publish path only sees normalized room-health fields.

Sensor and transport notes

The sensor choice matters because the room-health story is no longer limited to temperature. By adding the SCD41, the system gets a direct CO2 signal, along with temperature and humidity readings that help describe the indoor environment more clearly.

At a system level, the data path stays simple:

  • the STM32 device firmware reads the SCD41 over I2C
  • the device publishes a room-health payload over MQTT
  • the Rust gateway carries the edge-to-cloud path
  • indoor-climate alert thresholds are configured in application.conf

This keeps the device firmware focused on reading sensors and publishing measurements. The monitoring layer is responsible for deciding when a value becomes operationally interesting.

That separation matters in practice. If a room, office, lab, cabinet, or meeting space needs different thresholds, the rules can be adjusted in configuration instead of requiring a firmware change and device redeploy.

That is a more practical operating model than hardcoding every room-specific rule into the IoT device firmware.

SCD41 infrared CO2 sensor module used for the STM32 indoor climate monitoring slice

Image description: The SCD4x family sensor module adds CO2 sensing plus temperature and humidity support to the STM32 room-health setup through an I2C-connected, modular device integration.

Alarm handling

The CO2 alarm policy is not buried in the device firmware. The STM32 publishes measurements upstream, and the monitoring layer evaluates indoor-climate thresholds from application.conf.

That gives the team room to:

  • tune indoor-air-quality thresholds for each room or deployment
  • adjust alarm messages without changing the sensor driver
  • inspect whether the problem is a real room-health event or a telemetry-quality issue
  • reuse the same signal as input for audit findings, hardening work, and operational review

This is a cleaner operational split than hardcoding every indoor-climate decision into the device. The firmware stays focused on measurement and publishing. The monitoring layer owns interpretation, thresholds, and alert behavior.

Practical takeaways

For readers working on similar systems, the most useful parts of this setup are:

  • the board-level sensor integration is simple enough to understand and extend
  • the telemetry path remains easy to inspect after adding a new sensor
  • tthe monitor shows both current room state and recent alert history
  • CO2 adds a more useful indoor-air-quality signal than temperature and humidity alone
  • configuration-driven alarms make the system easier to adapt across environments

In other words, the work adds value at two levels. It improves the device by adding a better sensor signal, and it improves operations by making that signal easier to interpret, tune, and trust once it reaches the monitoring layer.

Where this pattern is useful

This pattern is useful when:

  • a team needs a believable STM32 to MQTT to monitor slice before rollout
  • room, office, lab, or enclosure telemetry is visible but not decision-ready
  • indoor-air-quality alarms exist, but do not carry enough context to trust them
  • sensor integration must stay modular instead of being hard-wired through the full stack
  • dashboards show symptoms, but not enough context to decide the next step

The main benefit is that the system does not stop at “we can read sensor values.” It creates a path from physical measurement to operational evidence. That makes it easier to decide whether the next action is threshold tuning, firmware refinement, sensor investigation, or a deeper review of the telemetry path.

Next step

If you are building a similar indoor-climate path, start with one device, one sensor path, and one monitor view you can verify end to end.

That usually exposes the real questions faster: whether the readings stay fresh, whether the threshold logic is sensible, whether the device payload is shaped well enough for monitoring, and whether the room-health story still makes sense once it leaves the board.

Thomas Bonderup

Thomas Bonderup

Senior IoT Consultant

Specializes in edge gateways, MQTT and TLS posture, observability and telemetry trust, edge-to-cloud audits.

Keep the conversation moving

If this reference looks close to your environment, connect before the next review gets harder.

These references come from direct delivery work. If your system looks similar, LinkedIn is an easy way to connect before deciding whether a deeper audit conversation makes sense.

Technical scope: edge gateways, MQTT and TLS posture, observability and telemetry trust, edge-to-cloud audits.

How engagements usually move

References should make the Audit to Sprint path easier to understand.

1. Audit the system under pressure

Baseline the selected assets, message paths, and operational risks with evidence leadership can act on.

2. Run a focused Sprint on the highest-impact findings

Fix the security, reliability, or telemetry gaps that are most likely to create downtime, review friction, or expensive rework.

3. Keep posture from drifting

Use a light retainer rhythm when the architecture is changing or customer pressure keeps moving.

Need this kind of evidence for your own IoT system?

Send the system slice you want reviewed and what is creating urgency. I’ll reply with a focused recommendation on audit scope, expected outputs, and whether a Sprint should follow.

Or contact me directly: +45 22 39 34 91 or tb@combotto.io.

Required fields are marked with *.

Next step

Typical response: same business day.