Skip to content

Add BME680 IAQ (air quality index) to BMP sensor driver - #25175

Open
eku wants to merge 3 commits into
arendst:developmentfrom
eku:feature/bmp_airquality
Open

eku wants to merge 3 commits into
arendst:developmentfrom
eku:feature/bmp_airquality

Conversation

@eku

@eku eku commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Description:

The BME680 gas sensor reports only an ohmic resistance, not a CO2 concentration, so derive an indoor air quality index from the gas resistance instead. The scale follows the Bosch BSEC convention of 0 (clean air) to 500 (heavily polluted air) documented in the BME680 datasheet, Sec. 4.2 and Table 4
(https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bme680-ds001.pdf).

The arithmetic is an adaptation of the basic open-source IAQ calculator in thstielow/raspi-bme680-iaq (https://github.com/thstielow/raspi-bme680-iaq), which circumvents the closed-source BSEC library by squaring the ratio between the gas resistance and a baseline ceiling value for a steeper slope at high air quality and rescaling the result to a 0-500 index. The percentage-based gas quality and the baseline/drift handling are inspired by the example in the Pimoroni bme680-python library (https://github.com/pimoroni/bme680-python). In place of the linear rescale IAQ = (1 - AQ/100) * 500 used there, this driver maps the squared excess ratio through a saturating Hill function u = (k * x)^2 with IAQ = 500 * u / (1 + u), where x = R_baseline / R_gas - 1 and resistance below the baseline is clamped to zero. A tVOC equivalent in ppb is derived from the same excess ratio with a saturating (Michaelis-Menten style) curve.

The metal-oxide resistance also depends on ambient humidity, so the reading is normalized to a dry-air equivalent before it enters the baseline and the ratio. As in the thstielow reference, absolute humidity is derived from temperature and relative humidity with the Magnus equation and the ideal gas law, and the resistance is scaled by exp(ph_slope * hum_abs), with ph_slope defaulting to 0.03 per g/m3. This keeps the index stable across humidity changes instead of drifting with them.

The baseline is the clean-air ceiling of that compensated resistance. It is learned as a running mean during an initial burn-in of 2880 measurements, about 96 minutes at the 2 second reading interval, and then adapted in two directions: towards cleaner readings with an exponential moving average of 0.005, a time constant of about 7 minutes, and back down with a time constant of about 2.3 days to follow the slow aging of the metal-oxide layer. Only the clean-air direction moves the ceiling quickly, so a pollution event does not drag the reference along with it and flatten the index.

Readings outside 1..5000 kOhm are dropped from the index, which rejects individual sensor glitches while leaving clean, dry air well inside the range, and a stored baseline is validated against the wider range that the humidity compensation can produce.

MQTT and the JSON API report the raw 0-500 index and the tVOC value in ppb. The WebUI shows the same information as a 0-100 quality percentage where 100 is clean air, plus the tVOC in ppb, both with localized labels from the language files. While the burn-in runs, the WebUI reports the calibration progress instead, and no index is published until it is based on a real baseline.

The console command BmpIaq reports, sets and resets the gas baseline:

  • BmpIaq prints the current baseline as the uncompensated resistance in kOhm, directly comparable with the Gas reading
  • BmpIaq <1..5000> stores the value as the calibrated clean-air baseline in kOhm, normalized to dry air for the ratio; values outside 1..5000 kOhm are rejected
  • BmpIaq Reset discards the learned baseline and restarts the burn-in

The baseline is written to flash as soon as the burn-in completes and again on a graceful restart or OTA update through the save-before-restart hook. It is kept in a free area of the reserved TSettings block on every CPU target, so the calibration survives reboots and updates without frequent flash writes.

Bildschirmfoto vom 2026-10-22 17-14-35

Related issue (if applicable): fixes #

Checklist:

  • The pull request is done against the latest development branch
  • Only relevant files were touched
  • Only one feature/fix was added per PR and the code change compiles without warnings
  • The code change is tested and works with Tasmota core ESP8266 V.2.7.8
  • The code change is tested and works with Tasmota core ESP32 V.3.3.12 from Platform 2026.09.50
  • I accept the CLA.

NOTE: The code change must pass CI tests. Your PR cannot be merged unless tests pass

The BME680 gas sensor reports only an ohmic resistance, not a CO2
concentration, so derive an indoor air quality index from the gas
resistance instead. The scale follows the Bosch BSEC convention of 0
(clean air) to 500 (heavily polluted air) documented in the BME680
datasheet, Sec. 4.2 and Table 4
(https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bme680-ds001.pdf).

The arithmetic is an adaptation of the basic open-source IAQ calculator
in thstielow/raspi-bme680-iaq (https://github.com/thstielow/raspi-bme680-iaq),
which circumvents the closed-source BSEC library by squaring the ratio
between the gas resistance and a baseline ceiling value for a steeper
slope at high air quality and rescaling the result to a 0-500 index. The
percentage-based gas quality and the baseline/drift handling are
inspired by the example in the Pimoroni bme680-python library
(https://github.com/pimoroni/bme680-python). In place of the linear
rescale IAQ = (1 - AQ/100) * 500 used there, this driver maps the
squared excess ratio through a saturating Hill function u = (k * x)^2
with IAQ = 500 * u / (1 + u), where x = R_baseline / R_gas - 1 and
resistance below the baseline is clamped to zero. A tVOC equivalent in
ppb is derived from the same excess ratio with a saturating
(Michaelis-Menten style) curve.

The metal-oxide resistance also depends on ambient humidity, so the
reading is normalized to a dry-air equivalent before it enters the
baseline and the ratio. As in the thstielow reference, absolute humidity
is derived from temperature and relative humidity with the Magnus
equation and the ideal gas law, and the resistance is scaled by
exp(ph_slope * hum_abs), with ph_slope defaulting to 0.03 per g/m3. This
keeps the index stable across humidity changes instead of drifting with
them.

The baseline is the clean-air ceiling of that compensated resistance. It
is learned as a running mean during an initial burn-in of 2880
measurements, about 96 minutes at the 2 second reading interval, and
then adapted in two directions: towards cleaner readings with an
exponential moving average of 0.005, a time constant of about 7 minutes,
and back down with a time constant of about 2.3 days to follow the slow
aging of the metal-oxide layer. Only the clean-air direction moves the
ceiling quickly, so a pollution event does not drag the reference along
with it and flatten the index.

Readings outside 1..5000 kOhm are dropped from the index, which rejects
individual sensor glitches while leaving clean, dry air well inside the
range, and a stored baseline is validated against the wider range that
the humidity compensation can produce.

MQTT and the JSON API report the raw 0-500 index and the tVOC value in
ppb. The WebUI shows the same information as a 0-100 quality percentage
where 100 is clean air, plus the tVOC in ppb, both with localized labels
from the language files. While the burn-in runs, the WebUI reports the
calibration progress instead, and no index is published until it is
based on a real baseline.

The console command BmpIaq reports, sets and resets the gas baseline:

  BmpIaq              prints the current baseline as the uncompensated
                      resistance in kOhm, directly comparable with the
                      Gas reading
  BmpIaq <1..5000>    stores the value as the calibrated clean-air
                      baseline in kOhm, normalized to dry air for the
                      ratio; values outside 1..5000 kOhm are rejected
  BmpIaq Reset        discards the learned baseline and restarts the
                      burn-in

The baseline is written to flash as soon as the burn-in completes and
again on a graceful restart or OTA update through the save-before-restart
hook. It is kept in a free area of the reserved TSettings block on
every CPU target, so the calibration survives reboots and
updates without frequent flash writes.

@s-hadinger s-hadinger left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please review the Settings part

Comment thread tasmota/include/tasmota_types.h Outdated
#if CONFIG_IDF_TARGET_ESP32S31
uint8_t ws_color[4][3]; // EB0
uint8_t free_esp32s31_ebc[8]; // EBC
#ifdef USE_BME68X

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The definition inside Settings should not depend on compile option. The value is there, the compile options only dictate whether the values are used or not.

Also it looks like it's only for ESP32S31, is that normal?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. It’s wrong.

@eku eku Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have I understood you correctly that values in TSettings are always retained, even when the code that uses them is not active?

Please suggest a free value that the code could use.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. That's the only way to support different drivers/sensors on the same device.

Let me see where to find four bytes for a single float...

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be the preferred solution:

  uint16_t      zcdimmerset[5];            // EA6
#if CONFIG_IDF_TARGET_ESP32S31
  uint8_t       ws_color[4][3];            // EB0
#else

  uint8_t       free_eb0[12];              // EB0  All but ESP32-S31 !!!

#endif

  uint8_t       free_ebc[4];               // EBC

  float         bme680_iaq_baseline;       // EC0
  uint16_t      light_pixels_height_1 : 15;// EC4  Pixels height minus 1, default 0 (0 means 1 line)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the GUI Air quality different from the JSON Air quality:

"MQTT and the JSON API report the raw 0-500 index and the tVOC value in ppb. The WebUI shows the same information as a 0-100 quality percentage where 100 is clean air."

The idea behind this is to display a value for the user in the GUI, but to pass on the raw value for any further calculations that may be required, as we do not know how it will be used at the destination.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me add that by using an integer we'll loose the decimal part. Can you verify that losing the decimals isn't a big issue? I suppose you can test your current setup with integer calibration value and see if the outcome differs a lot.

Give me a bit of time to answer that question. I need to test it. I’ll get back to you.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case of GUI percentage pls add the percent sign %

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. The stored float is not the raw BmpIaq <1..5000 kOhm> calibration value. It is the humidity-compensated baseline:

comp_gas = gas_kohm * exp(0.03f * absolute_humidity);
baseline = comp_gas;   // or the mean of it

So the stored value is typically a factor of ~1.0–2.3 above the raw value, i.e. roughly 1..12000 kOhm in practice; validation allows up to BME680_IAQ_BASE_MAX = 20000.0f. The 0..5000 kOhm range is only the raw gas resistance / the argument to BmpIaq.

That said, a uint16_t still handles this range easily (0..65535), even with 1 kOhm resolution.

@eku eku Nov 2, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case of GUI percentage pls add the percent sign %

The value is normalized and is therefore not automatically expressed as a percentage. 100% IAQ sounds a bit odd.

Erik Kunze added 2 commits November 4, 2026 17:20
holding one baseline per sensor, replacing the
single float.

Removed the hardcoded first-sensor index;
Loop over all detected sensors.

Bme680IaqCommand: reworked to control per-sensor

* BmpIaq -> report every BME680 baseline
* BmpIaq <n> -> report baseline of BME680 n (0-3)
* BmpIaq <n>,<value> -> set baseline of sensor n
* BmpIaq Reset -> reset all sensors
* BmpIaq <n>,Reset -> reset sensor n

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants