Remove proprietary Bosch BSEC blob; open in-tree IAQ estimator for BME680 (#11381)

* Remove proprietary Bosch BSEC blob; open in-tree IAQ estimator for BME680

BSEC2 cost ~37-39 KB flash and ~4-5 KB static RAM on ~190 of ~240 build
targets, linked whether or not a BME680 was attached, and was a no-source
proprietary archive inside GPLv3 release binaries. The firmware consumed
exactly one BSEC-exclusive output: the IAQ value.

- New BME680IaqEstimator: clean-room log-domain baseline tracker
  (humidity-compensated gas resistance vs a rise-fast/decay-slow ceiling,
  0-500 scale matching the existing UI bands), pure math, unit-tested on
  native (test_bme680_iaq, 15 tests incl. a deep-sleep reboot simulation).
  Warm-up/burn-in progress persists to /prefs/bme680.dat via SafeFile so
  one-sample-per-wake SENSOR nodes converge across reboots; stale
  /prefs/bsec.dat is removed once.
- BME680Sensor: single-path rewrite on Adafruit_BME680 with async
  once-per-minute sampling (~20x lower heater duty than BSEC LP mode),
  a hard 2-minute publish-freshness bound (a dead sensor stops reporting
  instead of freezing its last reading on the wire), and suppression of
  bogus gas_resistance=0 points from heater-unstable cycles.
- platformio.ini: environmental_extra_common/_extra/_no_bsec collapsed
  into one section; Bosch BSEC2 + BME68x deps deleted; per-variant BSEC
  link-path hacks and the TEMPORARY promicro lib_ignore removed.
  nrf52_promicro_diy_tcxo regains BME680 support at 36 KB clear of the
  warm-store cap; rak4631 lands at 75 KB clear.
- EnvironmentTelemetry: iaq rendering gates on has_iaq (a genuine IAQ of
  0 now displays); stale BSEC comments rewritten.
- rak4631 size budgets tightened (113000->108000 RAM, 786000->746000
  flash) to lock in the reclaimed headroom.
- bin/bme680_iaq_replay.cpp: host-side replay harness for tuning the
  estimator against captured BSEC traces (mean abs error + band
  agreement), no reflashing needed.

Measured (develop -> this branch): rak4631 -38.8 KB flash / -4.9 KB RAM;
heltec-v3 -36.4 KB / -4.0 KB; tlora-v2-1-1_6 +1.3 KB (its IAQ
approximation had been dead code since #9663 due to an inverted isfinite
check and now actually runs).

Note: gas_resistance stays kOhm on the wire for fleet compatibility; the
proto comment claiming MOhm gets a separate meshtastic/protobufs docs PR.

* Address CodeRabbit review feedback

- Use Throttle::isWithinTimespanMs for all elapsed-time predicates in
  BME680Sensor per coding guidelines (deadline math for the async reading
  completion stays raw, as it targets an absolute timestamp)
- Make the state file name members static constexpr
- Replay tool: cast uint16_t before %u (default argument promotion), report
  malformed input lines instead of silently skipping, and fail non-zero on
  stream read errors

* Address CodeRabbit nitpicks

- Replace the local clampf helper with std::clamp (meshUtils.h's clamp drags
  in Arduino.h, which would break the estimator's standalone host build that
  the replay harness depends on)
- Trim the replay tool's file header to a two-line summary; the full build,
  capture, and tuning workflow moves to docs/bme680_iaq_replay.md
This commit is contained in:
Ben Meadors
2026-08-13 13:21:16 -04:00
committed by GitHub
parent 3e71c679c1
commit b565a07a83
15 files changed
+927 -226

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
// Replays a captured BME680 CSV trace (gas_ohms,rh[,bsec_iaq]) through
// BME680IaqEstimator for offline tuning. See docs/bme680_iaq_replay.md.
#include "modules/Telemetry/Sensor/BME680IaqEstimator.h"
#include <cmath>
#include <cstdio>
namespace
{
// Same buckets the device UI uses (EnvironmentTelemetry drawFrame)
int band(int iaq)
{
if (iaq <= 25)
return 0; // Excellent
if (iaq <= 50)
return 1; // Good
if (iaq <= 100)
return 2; // Moderate
if (iaq <= 150)
return 3; // Poor
if (iaq <= 200)
return 4; // Unhealthy
if (iaq <= 300)
return 5; // Very Unhealthy
return 6; // Hazardous
}
} // namespace
int main(int argc, char **argv)
{
FILE *in = stdin;
if (argc > 1) {
in = fopen(argv[1], "r");
if (!in) {
fprintf(stderr, "cannot open %s\n", argv[1]);
return 1;
}
}
BME680IaqEstimator est;
char line[256];
long lineNo = 0, n = 0, skipped = 0, produced = 0, compared = 0, bandHits = 0;
double absErrSum = 0;
printf("n,gas_ohms,rh,est_iaq,bsec_iaq\n");
while (fgets(line, sizeof(line), in)) {
lineNo++;
if (line[0] == '#' || line[0] == '\n')
continue;
float gas, rh, bsec = NAN;
int fields = sscanf(line, "%f,%f,%f", &gas, &rh, &bsec);
if (fields < 2) {
// Tolerate one header row silently; anything else malformed is
// reported so a damaged trace can't produce a quiet, biased summary
if (lineNo > 1) {
skipped++;
fprintf(stderr, "skipping malformed line %ld: %s", lineNo, line);
}
continue;
}
n++;
uint16_t iaq;
bool got = est.update(gas, rh, &iaq);
bool haveBsec = fields >= 3 && std::isfinite(bsec);
printf("%ld,%.0f,%.2f,", n, gas, rh);
if (got)
printf("%u", (unsigned)iaq);
if (haveBsec)
printf(",%.0f\n", bsec);
else
printf(",\n");
if (got) {
produced++;
if (haveBsec) {
compared++;
absErrSum += std::fabs((double)iaq - (double)bsec);
if (band(iaq) == band((int)std::lround(bsec)))
bandHits++;
}
}
}
if (ferror(in)) {
fprintf(stderr, "input read error at line %ld\n", lineNo);
if (in != stdin)
fclose(in);
return 1;
}
fprintf(stderr, "samples: %ld, estimator outputs: %ld, malformed lines skipped: %ld\n", n, produced, skipped);
if (compared) {
fprintf(stderr, "vs BSEC (%ld comparable): mean abs error %.1f IAQ points, band agreement %.1f%%\n", compared,
absErrSum / compared, 100.0 * bandHits / compared);
}
if (in != stdin)
fclose(in);
return 0;
}
+2 -2
View File
@@ -18,7 +18,7 @@
"description."
],
"rak4631": {
"ram_bytes": 113000,
"flash_bytes": 786000
"ram_bytes": 108000,
"flash_bytes": 746000
}
}
+54
View File
@@ -0,0 +1,54 @@
# BME680 IAQ replay harness
`bin/bme680_iaq_replay.cpp` replays a captured sensor trace through the in-tree
`BME680IaqEstimator` on a dev machine, for tuning the estimator's constants
against recorded Bosch BSEC output. The estimator is pure math with no platform
dependencies, so a trace replays in milliseconds - edit the constants in
`src/modules/Telemetry/Sensor/BME680IaqEstimator.h`, recompile, rerun.
## Build
From the repo root:
```bash
c++ -std=c++17 -O2 -I src -o /tmp/iaq_replay \
bin/bme680_iaq_replay.cpp src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp
```
## Input
CSV on stdin or as a file argument, one sample per line:
```text
gas_ohms,relative_humidity[,bsec_iaq]
```
Lines starting with `#` are ignored; a single non-numeric header row is
tolerated; any other malformed line is reported on stderr and skipped.
## Capturing a trace
On a firmware build that still links BSEC (any release tag before the BSEC
removal), add one log line to `BME680Sensor::getMetrics` in the BSEC branch:
```cpp
LOG_INFO("IAQCSV,%.0f,%.2f,%.0f", bme680.getData(BSEC_OUTPUT_RAW_GAS).signal,
bme680.getData(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY).signal,
bme680.getData(BSEC_OUTPUT_IAQ).signal);
```
then extract the columns from the serial log:
```bash
grep -o 'IAQCSV,.*' serial.log | cut -d, -f2- > trace.csv
```
BSEC's `RAW_GAS` and heat-compensated humidity are exactly the estimator's
inputs, so one physical sensor feeds both algorithms identically.
## Output
Per-sample CSV `n,gas_ohms,rh,est_iaq,bsec_iaq` on stdout (empty `est_iaq`
during the estimator's warm-up/burn-in window), plus a stderr summary with the
mean absolute error and UI-band agreement against the `bsec_iaq` column, using
the same 0-500 band thresholds the device screen applies.
+6 -17
View File
@@ -230,8 +230,11 @@ lib_deps =
# renovate: datasource=github-tags depName=Seeed_PM2_5_sensor_HM3301 packageName=meshtastic/Seeed_PM2_5_sensor_HM3301
https://github.com/meshtastic/Seeed_PM2_5_sensor_HM3301/archive/2704ca254c7e2136c52ac23198dd05f5ba1e2f04.zip
; Common environmental sensor libraries (not included in native / portduino)
[environmental_extra_common]
; Extra environmental sensor libraries (not included in native / portduino).
; BME680/BME688 IAQ comes from the in-tree open estimator (BME680IaqEstimator);
; the proprietary Bosch BSEC blob (measured ~37-39 KB flash + ~4-5 KB static
; RAM per image) is intentionally not linked anywhere.
[environmental_extra]
lib_deps =
# renovate: datasource=github-tags depName=Adafruit BMP3XX packageName=adafruit/Adafruit_BMP3XX
https://github.com/adafruit/Adafruit_BMP3XX/archive/refs/tags/2.1.6.zip
@@ -260,20 +263,6 @@ lib_deps =
# renovate: datasource=custom.pio depName=Adafruit ADS1X15 packageName=adafruit/library/Adafruit ADS1X15 Library
https://github.com/adafruit/Adafruit_ADS1X15/archive/refs/tags/2.6.2.zip
# renovate: datasource=github-tags depName=Adafruit DS248x packageName=adafruit/Adafruit_DS248x
https://github.com/adafruit/Adafruit_DS248x/archive/refs/tags/1.2.0.zip
; Environmental sensors with BSEC2 (Bosch proprietary IAQ)
[environmental_extra]
lib_deps =
${environmental_extra_common.lib_deps}
# renovate: datasource=github-tags depName=Bosch BSEC2 packageName=boschsensortec/Bosch-BSEC2-Library
https://github.com/boschsensortec/Bosch-BSEC2-Library/archive/refs/tags/1.10.2610.zip
# renovate: datasource=github-tags depName=Bosch BME68x packageName=boschsensortec/Bosch-BME68x-Library
https://github.com/boschsensortec/Bosch-BME68x-Library/archive/refs/tags/v1.3.40408.zip
; Environmental sensors without BSEC (saves ~3.5KB DRAM for original ESP32 targets)
[environmental_extra_no_bsec]
lib_deps =
${environmental_extra_common.lib_deps}
https://github.com/adafruit/Adafruit_DS248x/archive/refs/tags/1.2.0.zip
# renovate: datasource=github-tags depName=Adafruit_BME680 packageName=adafruit/Adafruit_BME680
https://github.com/adafruit/Adafruit_BME680/archive/refs/tags/2.0.6.zip
@@ -54,7 +54,7 @@ extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const c
#include "Sensor/LTR390UVSensor.h"
#endif
#if __has_include(<bsec2.h>) || __has_include(<Adafruit_BME680.h>)
#if __has_include(<Adafruit_BME680.h>)
#include "Sensor/BME680Sensor.h"
#endif
@@ -306,7 +306,7 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner)
#if __has_include(<Adafruit_LTR390.h>)
addSensor<LTR390UVSensor>(i2cScanner, ScanI2C::DeviceType::LTR390UV);
#endif
#if __has_include(<bsec2.h>) || __has_include(<Adafruit_BME680.h>)
#if __has_include(<Adafruit_BME680.h>)
addSensor<BME680Sensor>(i2cScanner, ScanI2C::DeviceType::BME_680);
#endif
#if __has_include(<Adafruit_BMP280.h>)
@@ -457,7 +457,8 @@ int32_t EnvironmentTelemetryModule::runOnce()
if (sleepOnNextExecution) {
// Honor the pre-sleep grace period armed in sendTelemetry(): OSThread reschedules with
// this return value, which would otherwise override setIntervalFromNow() with the sensor
// polling interval (35 ms for BSEC2) and trigger deep sleep while the TX is still on air
// polling interval (sub-second while a BME680 reading is in flight) and trigger deep sleep
// while the TX is still on air
return FIVE_SECONDS_MS;
}
return min(sendToPhoneIntervalMs, result);
@@ -520,7 +521,7 @@ void EnvironmentTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSt
const auto &m = telemetry.variant.environment_metrics;
// Check if any telemetry field has valid data
bool hasAny = m.has_temperature || m.has_relative_humidity || m.barometric_pressure != 0 || m.iaq != 0 || m.voltage != 0 ||
bool hasAny = m.has_temperature || m.has_relative_humidity || m.barometric_pressure != 0 || m.has_iaq || m.voltage != 0 ||
m.current != 0 || m.lux != 0 || m.white_lux != 0 || m.weight != 0 || m.distance != 0 || m.radiation != 0;
if (!hasAny) {
@@ -555,7 +556,7 @@ void EnvironmentTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSt
entries.push_back("Hum: " + String(m.relative_humidity, 0) + "%");
if (m.barometric_pressure != 0)
entries.push_back("Prss: " + String(m.barometric_pressure, 0) + " hPa");
if (m.iaq != 0) {
if (m.has_iaq) {
String aqi = "IAQ: " + String(m.iaq);
const char *bannerMsg = nullptr; // Default: no banner
@@ -844,7 +845,7 @@ bool EnvironmentTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
}
// Arm the pre-sleep sequence even when no valid reading was available this cycle (e.g. a
// BSEC2 call timing violation): a power-saving SENSOR node must still return to deep sleep,
// failed sensor read): a power-saving SENSOR node must still return to deep sleep,
// otherwise it stays awake until the next telemetry interval and drains its battery
if (!phoneOnly && isPowerSavingSensor()) {
if (!validTelemetry)
@@ -0,0 +1,94 @@
#include "BME680IaqEstimator.h"
// std::clamp rather than meshUtils.h's clamp: that header drags in Arduino.h,
// and this file must stay compilable standalone on a dev host (see the replay
// harness in bin/bme680_iaq_replay.cpp)
#include <algorithm>
#include <math.h>
#include <string.h>
bool BME680IaqEstimator::update(float gasOhms, float relativeHumidity, uint16_t *iaqOut)
{
if (!(isfinite(gasOhms) && gasOhms > 0.0f))
return false;
// A failed humidity read must not poison the baseline: fall back to the
// reference, which makes both compensation terms no-ops
float rh = isfinite(relativeHumidity) ? std::clamp(relativeHumidity, 0.0f, 100.0f) : RH_REF;
if (warmupRemaining > 0) {
warmupRemaining--;
return false;
}
float x = logf(gasOhms) + KH * (rh - RH_REF);
x = std::clamp(x, LN_FLOOR - LN_RANGE, LN_CEIL_MAX);
if (!seeded) {
lnCeiling = std::clamp(x, LN_FLOOR, LN_CEIL_MAX);
seeded = true;
} else {
float alpha = (x > lnCeiling) ? ALPHA_UP : ALPHA_DOWN;
lnCeiling = std::clamp(lnCeiling + alpha * (x - lnCeiling), LN_FLOOR, LN_CEIL_MAX);
}
if (sampleCount < UINT32_MAX)
sampleCount++;
if (sampleCount < BURN_IN_SAMPLES)
return false;
float below = lnCeiling - x;
if (below < 0.0f)
below = 0.0f;
float gasScore = std::clamp(below / LN_RANGE, 0.0f, 1.0f) * 500.0f;
// Comfort-band penalty: only outside the band, so ordinary indoor humidity
// can't keep IAQ away from the "Excellent" band
float humDeviation = rh < RH_COMFORT_MIN ? RH_COMFORT_MIN - rh : (rh > RH_COMFORT_MAX ? rh - RH_COMFORT_MAX : 0.0f);
float humScore = std::clamp(humDeviation / RH_DEV_NORM, 0.0f, 1.0f) * 500.0f;
*iaqOut = (uint16_t)lroundf(std::clamp(gasScore + HUM_WEIGHT * humScore, 0.0f, 500.0f));
return true;
}
uint32_t BME680IaqEstimator::computeHash(const BME680IaqState &s)
{
uint32_t words[5];
memcpy(words, &s, sizeof(words));
return words[0] ^ words[1] ^ words[2] ^ words[3] ^ words[4];
}
void BME680IaqEstimator::serialize(BME680IaqState *out, uint32_t nowSecs) const
{
memset(out, 0, sizeof(*out));
out->magic = MAGIC;
out->version = VERSION;
out->warmupRemaining = (uint8_t)warmupRemaining;
out->lnCeiling = lnCeiling;
out->savedAtSecs = nowSecs;
out->sampleCount = sampleCount;
out->xorHash = computeHash(*out);
}
bool BME680IaqEstimator::restore(const BME680IaqState &in, uint32_t nowSecs)
{
if (in.magic != MAGIC || in.version != VERSION)
return false;
if (in.xorHash != computeHash(in))
return false;
// The ceiling only exists once a sample has been accepted (sampleCount > 0);
// pure warm-up progress is persisted with lnCeiling still at 0
bool hasBaseline = in.sampleCount > 0;
if (hasBaseline && !(isfinite(in.lnCeiling) && in.lnCeiling >= LN_FLOOR && in.lnCeiling <= LN_CEIL_MAX))
return false;
// Staleness is only judgeable when the state was stamped with a valid RTC
// and we have one now; a week-old baseline says nothing about today's air
if (in.savedAtSecs != 0 && nowSecs != 0 && nowSecs >= in.savedAtSecs && (nowSecs - in.savedAtSecs) > STATE_MAX_AGE_SECS)
return false;
lnCeiling = in.lnCeiling;
sampleCount = in.sampleCount;
warmupRemaining = in.warmupRemaining <= WARMUP_DISCARD ? in.warmupRemaining : WARMUP_DISCARD;
seeded = hasBaseline;
return true;
}
@@ -0,0 +1,104 @@
#pragma once
#include <stdint.h>
/**
* Persisted estimator state, written to /prefs/bme680.dat via SafeFile.
* Fixed 24-byte little-endian layout; xorHash covers the five preceding words
* as a semantic guard on top of SafeFile's write-path hash.
*/
struct BME680IaqState {
uint32_t magic;
uint8_t version;
uint8_t warmupRemaining;
uint8_t reserved[2];
float lnCeiling;
uint32_t savedAtSecs; // RTC epoch at save; 0 if no valid RTC
uint32_t sampleCount;
uint32_t xorHash;
};
static_assert(sizeof(BME680IaqState) == 24, "BME680IaqState layout must stay fixed for on-disk compatibility");
/**
* Clean-room IAQ estimator for the BME680/BME688 gas sensor (replaces the
* proprietary Bosch BSEC library).
*
* VOC exposure lowers the sensor's gas resistance. We track a rolling ceiling
* of humidity-compensated log-resistance ("cleanest air seen recently") and
* score each sample by its log-distance below that ceiling, mapped onto the
* 0-500 scale the UI already bands (<=25 Excellent ... >300 Hazardous).
*
* Warm-up and burn-in progress are part of the persisted state: a deep-sleep
* SENSOR node that takes one sample per wake (RAM wiped in between) still
* converges by restoring and re-serializing across reboots.
*
* Pure math on purpose: no Arduino, filesystem, or clock dependencies, so the
* whole thing is unit-testable on the native host (test_bme680_iaq).
*/
class BME680IaqEstimator
{
public:
static constexpr uint32_t MAGIC = 0x42494151; // 'BIAQ'
static constexpr uint8_t VERSION = 1;
// Tunables, centralized for the hardware-soak stage. Physical rationale:
// KH: gas resistance falls roughly exp(-0.035 * %RH); compensate to a 40 %RH reference
// ALPHA_UP/DOWN: ceiling rises fast toward cleaner air, decays with a ~12 h time
// constant at one sample per minute so pollution episodes don't become "normal"
// LN_FLOOR: baseline can't sit below ln(5 kOhm), the heavily-polluted end of the range
// LN_CEIL_MAX: sanity bound only -- fresh/very clean sensors legitimately read
// 1-13 MOhm (Bosch specs to 50 MOhm), so this sits far above at ln(~100 MOhm)
// LN_RANGE: gas at 1/15 of the baseline maps to IAQ 500
static constexpr float KH = 0.035f;
static constexpr float ALPHA_UP = 0.25f;
static constexpr float ALPHA_DOWN = 1.0f / 720.0f;
static constexpr float LN_FLOOR = 8.517193f; // ln(5000)
static constexpr float LN_CEIL_MAX = 18.4f; // ln(~1e8)
static constexpr float LN_RANGE = 2.7080502f; // ln(15)
static constexpr float HUM_WEIGHT = 0.15f;
// RH_REF: the KH compensation reference, and the fallback for failed humidity reads
// RH_COMFORT_MIN/MAX: no humidity penalty inside this band
// RH_DEV_NORM: deviation that earns the full penalty (== 100 - RH_COMFORT_MAX; the dry
// side's maximum deviation is only RH_COMFORT_MIN, so it intentionally caps at 75%)
static constexpr float RH_REF = 40.0f;
static constexpr float RH_COMFORT_MIN = 30.0f;
static constexpr float RH_COMFORT_MAX = 60.0f;
static constexpr float RH_DEV_NORM = 40.0f;
static constexpr uint32_t WARMUP_DISCARD = 3; // first-ever samples, while the heater element settles
static constexpr uint32_t BURN_IN_SAMPLES = 30; // no output until the baseline has this much history
static constexpr uint32_t STATE_MAX_AGE_SECS = 7 * 24 * 60 * 60; // a week-old baseline says nothing about today's air
/**
* Feed one sample. Returns true and writes *iaqOut (0-500) once the
* estimator has enough history; returns false during warm-up/burn-in or
* for invalid readings.
*/
bool update(float gasOhms, float relativeHumidity, uint16_t *iaqOut);
/// Burn-in complete: output is available
bool ready() const { return sampleCount >= BURN_IN_SAMPLES; }
// Progress accessors, used by the sensor to decide when persisting is worthwhile
uint32_t samplesFed() const { return sampleCount; }
uint32_t warmupLeft() const { return warmupRemaining; }
void serialize(BME680IaqState *out, uint32_t nowSecs) const;
/**
* Adopt persisted state, including warm-up/burn-in progress (warm-up is
* NOT re-armed: the persisted counters are the source of truth). Returns
* false and leaves the estimator untouched on magic, version, hash, or
* range mismatch, or if the state is older than STATE_MAX_AGE_SECS (only
* checkable when both timestamps are valid).
*/
bool restore(const BME680IaqState &in, uint32_t nowSecs);
private:
static uint32_t computeHash(const BME680IaqState &s);
float lnCeiling = 0.0f;
uint32_t sampleCount = 0; // samples fed to the baseline (excludes warm-up discards)
uint32_t warmupRemaining = WARMUP_DISCARD;
bool seeded = false;
};
+161 -144
View File
@@ -1,58 +1,25 @@
#include "configuration.h"
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && (__has_include(<bsec2.h>) || __has_include(<Adafruit_BME680.h>))
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include(<Adafruit_BME680.h>)
#include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "BME680Sensor.h"
#include "FSCommon.h"
#include "SPILock.h"
#include "SafeFile.h"
#include "TelemetrySensor.h"
#include "UptimeClock.h"
#include "gps/RTC.h"
#include "mesh/Throttle.h"
#if __has_include(<Adafruit_BME680.h>)
#include <cmath>
#endif
#include <math.h>
BME680Sensor::BME680Sensor() : TelemetrySensor(meshtastic_TelemetrySensorType_BME680, "BME680") {}
#if __has_include(<bsec2.h>)
int32_t BME680Sensor::runOnce()
{
if (!bme680.run()) {
checkStatus("runTrigger");
}
return 35;
}
#endif
bool BME680Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev)
{
status = 0;
#if __has_include(<bsec2.h>)
if (!bme680.begin(dev->address.address, *bus))
checkStatus("begin");
if (bme680.status == BSEC_OK) {
status = 1;
if (!bme680.setConfig(bsec_config)) {
checkStatus("setConfig");
status = 0;
}
loadState();
if (!bme680.updateSubscription(sensorList, ARRAY_LEN(sensorList), BSEC_SAMPLE_RATE_LP)) {
checkStatus("updateSubscription");
status = 0;
}
LOG_INFO("Init sensor: %s with the BSEC Library version %d.%d.%d.%d ", sensorName, bme680.version.major,
bme680.version.minor, bme680.version.major_bugfix, bme680.version.minor_bugfix);
}
if (status == 0)
LOG_DEBUG("BME680Sensor::runOnce: bme680.status %d", bme680.status);
#else
bme680 = makeBME680(bus);
if (!bme680->begin(dev->address.address)) {
@@ -60,154 +27,204 @@ bool BME680Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev)
return status;
}
status = 1;
// Acquisition profile, stated explicitly (these match the library defaults):
// the heater setting determines power draw, ~0.25% duty at one sample/min
bme680->setTemperatureOversampling(BME680_OS_8X);
bme680->setHumidityOversampling(BME680_OS_2X);
bme680->setPressureOversampling(BME680_OS_4X);
bme680->setIIRFilterSize(BME680_FILTER_SIZE_3);
bme680->setGasHeater(320, 150); // 320 degC for 150 ms
#endif
status = 1;
loadState();
LOG_INFO("Init sensor: %s (open IAQ estimator)", sensorName);
initI2CSensor();
return status;
}
int32_t BME680Sensor::runOnce()
{
uint32_t now = Time::getMillis();
if (readingInFlight) {
if (!Throttle::deadlinePassedAt(now, readingDoneAtMs))
return readingDoneAtMs - now;
captureSample();
return SAMPLE_INTERVAL_MS;
}
if (haveSample && Throttle::isWithinTimespanMs(lastSampleMs, SAMPLE_INTERVAL_MS))
return SAMPLE_INTERVAL_MS - (now - lastSampleMs);
uint32_t doneAt = bme680->beginReading();
if (doneAt == 0) {
LOG_WARN("%s beginReading() failed", sensorName);
return SAMPLE_INTERVAL_MS;
}
readingInFlight = true;
readingDoneAtMs = doneAt;
return Throttle::deadlinePassedAt(now, doneAt) ? 1 : (int32_t)(doneAt - now);
}
/// Complete the reading (in flight or synchronous), feed the estimator, refresh the cache
void BME680Sensor::captureSample()
{
readingInFlight = false;
// endReading() completes the in-flight conversion, or starts and finishes
// a fresh one when none is pending (performReading() is an alias for it in
// Adafruit_BME680; a failed first call resets the conversion, so the second
// call is a genuine one-shot retry). Worst case each call waits ~2x the
// remaining TPHG cycle, so a synchronous read costs a few hundred ms.
if (!bme680->endReading() && !bme680->performReading()) {
LOG_WARN("%s reading failed", sensorName);
return;
}
lastTemperature = bme680->temperature;
lastHumidity = bme680->humidity;
lastPressureHPa = bme680->pressure / 100.0F;
lastGasOhms = (float)bme680->gas_resistance;
haveSample = true;
lastSampleMs = Time::getMillis();
uint16_t iaq;
if (iaqEstimator.update(lastGasOhms, lastHumidity, &iaq)) {
lastIaq = iaq;
lastIaqValid = true;
lastIaqMs = lastSampleMs;
} else if (isfinite(lastGasOhms) && lastGasOhms > 0.0f) {
// Valid gas sample but the estimator has no output yet (warm-up/burn-in)
lastIaqValid = false;
} else if (lastIaqValid && !Throttle::isWithinTimespanMs(lastIaqMs, IAQ_CARRY_MS)) {
// Heater-unstable cycles (gas reported as 0) may ride on the previous
// IAQ briefly, but a persistently gasless sensor stops reporting IAQ
lastIaqValid = false;
}
maybeSaveState();
}
bool BME680Sensor::getMetrics(meshtastic_Telemetry *measurement)
{
#if __has_include(<bsec2.h>)
if (bme680.getData(BSEC_OUTPUT_RAW_PRESSURE).signal == 0)
if (!haveSample || !Throttle::isWithinTimespanMs(lastSampleMs, SAMPLE_FRESH_MS))
captureSample();
// A failed refresh must not freeze the last reading on the wire: publish
// only while the cache is genuinely fresh
if (!haveSample || !Throttle::isWithinTimespanMs(lastSampleMs, SAMPLE_FRESH_MS))
return false;
measurement->variant.environment_metrics.has_temperature = true;
measurement->variant.environment_metrics.has_relative_humidity = true;
measurement->variant.environment_metrics.has_barometric_pressure = true;
measurement->variant.environment_metrics.has_gas_resistance = true;
measurement->variant.environment_metrics.has_iaq = true;
measurement->variant.environment_metrics.temperature = bme680.getData(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE).signal;
measurement->variant.environment_metrics.relative_humidity =
bme680.getData(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY).signal;
measurement->variant.environment_metrics.barometric_pressure = bme680.getData(BSEC_OUTPUT_RAW_PRESSURE).signal;
measurement->variant.environment_metrics.gas_resistance = bme680.getData(BSEC_OUTPUT_RAW_GAS).signal / 1000.0;
// Check if we need to save state to filesystem (every STATE_SAVE_PERIOD ms)
measurement->variant.environment_metrics.iaq = bme680.getData(BSEC_OUTPUT_IAQ).signal;
updateState();
#else
if (!bme680->performReading()) {
LOG_ERROR("BME680Sensor::getMetrics: performReading failed");
return false;
measurement->variant.environment_metrics.temperature = lastTemperature;
measurement->variant.environment_metrics.relative_humidity = lastHumidity;
measurement->variant.environment_metrics.barometric_pressure = lastPressureHPa;
// A heater-unstable cycle reports gas_resistance 0; suppress the field
// rather than broadcasting a bogus 0 kOhm point
if (isfinite(lastGasOhms) && lastGasOhms > 0.0f) {
measurement->variant.environment_metrics.has_gas_resistance = true;
// Fleet convention is kOhm on the wire (despite the proto comment saying MOhm)
measurement->variant.environment_metrics.gas_resistance = lastGasOhms / 1000.0f;
}
measurement->variant.environment_metrics.has_temperature = true;
measurement->variant.environment_metrics.has_relative_humidity = true;
measurement->variant.environment_metrics.has_barometric_pressure = true;
measurement->variant.environment_metrics.has_gas_resistance = true;
measurement->variant.environment_metrics.temperature = bme680->readTemperature();
measurement->variant.environment_metrics.relative_humidity = bme680->readHumidity();
measurement->variant.environment_metrics.barometric_pressure = bme680->readPressure() / 100.0F;
float gasRaw = bme680->readGas();
measurement->variant.environment_metrics.gas_resistance = gasRaw / 1000.0;
// IAQ approximation: humidity-compensated logarithmic mapping of gas resistance
// Gas sensor resistance drops with humidity; compensate to a 40% RH reference baseline
// Map compensated gas resistance (Ohms) to IAQ 0-500 using log-linear interpolation
// Clean air reference ~400 kOhm, polluted reference ~5 kOhm
if (gasRaw > 0.0f && !isfinite(gasRaw)) {
static constexpr float LOG_UPPER = 12.899219f; // log(400k)
static constexpr float LOG_RANGE_INV = 1.0f / (12.899219f - 8.517193f); // 1 / (log(400k) - log(5k))
if (lastIaqValid) {
measurement->variant.environment_metrics.has_iaq = true;
measurement->variant.environment_metrics.iaq = (uint16_t)(fminf(
fmaxf(((LOG_UPPER -
logf(fmaxf(gasRaw * expf(0.035f * (measurement->variant.environment_metrics.relative_humidity - 40.0f)),
1.0f))) *
LOG_RANGE_INV) *
500.0f,
0.0f),
500.0f));
measurement->variant.environment_metrics.iaq = lastIaq;
}
#endif
return true;
}
#if __has_include(<bsec2.h>)
void BME680Sensor::loadState()
{
#ifdef FSCom
BME680IaqState state;
bool haveBlob = false;
spiLock->lock();
auto file = FSCom.open(bsecConfigFileName, FILE_O_READ);
auto file = FSCom.open(stateFileName, FILE_O_READ);
if (file) {
file.read((uint8_t *)&bsecState, BSEC_MAX_STATE_BLOB_SIZE);
haveBlob = file.read((uint8_t *)&state, sizeof(state)) == sizeof(state);
file.close();
bme680.setState(bsecState);
LOG_INFO("%s: state read from %s", sensorName, bsecConfigFileName);
} else {
LOG_INFO("No %s state found (File: %s)", sensorName, bsecConfigFileName);
}
// One-time cleanup of the proprietary-BSEC calibration blob from older firmware
if (FSCom.exists(legacyBsecStateFileName) && FSCom.remove(legacyBsecStateFileName))
LOG_INFO("%s removed legacy state file %s", sensorName, legacyBsecStateFileName);
spiLock->unlock();
if (!haveBlob) {
LOG_INFO("No %s state found (File: %s)", sensorName, stateFileName);
return;
}
if (iaqEstimator.restore(state, getValidTime(RTCQuality::RTCQualityDevice))) {
lastPersistedSampleCount = iaqEstimator.samplesFed();
lastPersistedWarmup = iaqEstimator.warmupLeft();
lastSaveEpochSecs = state.savedAtSecs;
LOG_INFO("%s IAQ state restored from %s (%u samples)", sensorName, stateFileName, iaqEstimator.samplesFed());
} else {
LOG_INFO("%s IAQ state in %s rejected (stale or invalid), starting fresh", sensorName, stateFileName);
}
#else
LOG_ERROR("Filesystem not implemented");
#endif
}
void BME680Sensor::updateState()
void BME680Sensor::maybeSaveState()
{
if (!iaqEstimator.ready()) {
// Persist warm-up/burn-in progress whenever it advances, so a
// deep-sleeping SENSOR node (one sample per wake, RAM wiped between)
// still converges. Bounded to ~33 writes over the sensor's lifetime.
if (iaqEstimator.samplesFed() != lastPersistedSampleCount || iaqEstimator.warmupLeft() != lastPersistedWarmup)
saveState();
return;
}
uint32_t nowSecs = getValidTime(RTCQuality::RTCQualityDevice);
if (nowSecs != 0 && lastSaveEpochSecs != 0) {
// RTC available: gate on wall-clock age so short deep-sleep wakes don't
// rewrite flash every time
if (nowSecs >= lastSaveEpochSecs && (nowSecs - lastSaveEpochSecs) < STATE_SAVE_PERIOD_SECS)
return;
} else {
// No RTC: gate on the persisted sample count (it survives reboots, so
// deep-sleeping RTC-less nodes still refresh their baseline every
// ~STATE_SAVE_PERIOD_MS worth of samples) with an uptime cadence as a
// secondary trigger for always-on nodes
if (iaqEstimator.samplesFed() - lastPersistedSampleCount < STATE_SAVE_PERIOD_MS / SAMPLE_INTERVAL_MS &&
!Throttle::hasElapsed(lastStateSaveMs, STATE_SAVE_PERIOD_MS))
return;
}
saveState();
}
void BME680Sensor::saveState()
{
#ifdef FSCom
spiLock->lock();
bool update = false;
if (stateUpdateCounter == 0) {
/* First state update when IAQ accuracy is >= 3 */
accuracy = bme680.getData(BSEC_OUTPUT_IAQ).accuracy;
if (accuracy >= 2) {
LOG_DEBUG("%s state update IAQ accuracy %u >= 2", sensorName, accuracy);
update = true;
stateUpdateCounter++;
} else {
LOG_DEBUG("%s not updated, IAQ accuracy is %u < 2", sensorName, accuracy);
}
} else {
/* Update every STATE_SAVE_PERIOD minutes */
// Interval since the last save; counter * period overflows uint32 past ~198 saves.
if (Throttle::hasElapsed(lastStateSaveMs, STATE_SAVE_PERIOD)) {
LOG_DEBUG("%s state update every %d minutes", sensorName, STATE_SAVE_PERIOD / 60000);
update = true;
stateUpdateCounter++;
}
}
BME680IaqState state;
uint32_t nowSecs = getValidTime(RTCQuality::RTCQualityDevice);
iaqEstimator.serialize(&state, nowSecs);
if (update) {
bme680.getState(bsecState);
if (FSCom.exists(bsecConfigFileName) && !FSCom.remove(bsecConfigFileName)) {
LOG_WARN("Can't remove old state file");
}
auto file = FSCom.open(bsecConfigFileName, FILE_O_WRITE);
if (file) {
LOG_INFO("%s: state write to %s", sensorName, bsecConfigFileName);
file.write((uint8_t *)&bsecState, BSEC_MAX_STATE_BLOB_SIZE);
file.flush();
file.close();
// Checkpoint on success only, so a failed write is retried at the next interval.
lastStateSaveMs = Time::getMillis();
} else {
LOG_INFO("Can't write %s state (File: %s)", sensorName, bsecConfigFileName);
}
// SafeFile takes the SPI lock itself; fullAtomic keeps the old state file
// in place until the verified replacement is renamed over it, so a power
// loss mid-save can't lose the banked burn-in progress (the blob is 24
// bytes, so the atomic path costs nothing)
auto file = SafeFile(stateFileName, true);
file.write((uint8_t *)&state, sizeof(state));
if (file.close()) {
lastPersistedSampleCount = iaqEstimator.samplesFed();
lastPersistedWarmup = iaqEstimator.warmupLeft();
lastSaveEpochSecs = nowSecs;
lastStateSaveMs = Time::getMillis();
LOG_DEBUG("%s state write to %s", sensorName, stateFileName);
} else {
LOG_WARN("Can't write %s state (File: %s)", sensorName, stateFileName);
}
spiLock->unlock();
#else
LOG_ERROR("Filesystem not implemented");
#endif
}
void BME680Sensor::checkStatus(const char *functionName)
{
if (bme680.status < BSEC_OK)
LOG_ERROR("%s BSEC2 code: %d", functionName, bme680.status);
else if (bme680.status > BSEC_OK)
LOG_WARN("%s BSEC2 code: %d", functionName, bme680.status);
if (bme680.sensor.status < BME68X_OK)
LOG_ERROR("%s BME68X code: %d", functionName, bme680.sensor.status);
else if (bme680.sensor.status > BME68X_OK)
LOG_WARN("%s BME68X code: %d", functionName, bme680.sensor.status);
}
#endif
#endif
+44 -39
View File
@@ -1,66 +1,71 @@
#include "configuration.h"
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && (__has_include(<bsec2.h>) || __has_include(<Adafruit_BME680.h>))
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include(<Adafruit_BME680.h>)
#include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "BME680IaqEstimator.h"
#include "TelemetrySensor.h"
#if __has_include(<bsec2.h>)
#include <bme68xLibrary.h>
#include <bsec2.h>
#else
#include <Adafruit_BME680.h>
#include <memory>
#endif
#define STATE_SAVE_PERIOD UINT32_C(360 * 60 * 1000) // That's 6 hours worth of millis()
#if __has_include(<bsec2.h>)
const uint8_t bsec_config[] = {
#include "config/bme680/bme680_iaq_33v_3s_4d/bsec_iaq.txt"
};
#endif
class BME680Sensor : public TelemetrySensor
{
private:
#if __has_include(<bsec2.h>)
Bsec2 bme680;
#else
using BME680Ptr = std::unique_ptr<Adafruit_BME680>;
static BME680Ptr makeBME680(TwoWire *bus) { return BME680Ptr(new Adafruit_BME680(bus)); }
BME680Ptr bme680;
#endif
BME680IaqEstimator iaqEstimator;
protected:
#if __has_include(<bsec2.h>)
const char *bsecConfigFileName = "/prefs/bsec.dat";
uint8_t bsecState[BSEC_MAX_STATE_BLOB_SIZE] = {0};
uint8_t accuracy = 0;
uint16_t stateUpdateCounter = 0;
uint32_t lastStateSaveMs = 0; // when the state blob was last written, for the save interval
bsecSensor sensorList[9] = {BSEC_OUTPUT_IAQ,
BSEC_OUTPUT_RAW_TEMPERATURE,
BSEC_OUTPUT_RAW_PRESSURE,
BSEC_OUTPUT_RAW_HUMIDITY,
BSEC_OUTPUT_RAW_GAS,
BSEC_OUTPUT_STABILIZATION_STATUS,
BSEC_OUTPUT_RUN_IN_STATUS,
BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE,
BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY};
static constexpr uint32_t SAMPLE_INTERVAL_MS = 60 * 1000;
// getMetrics() publishes the cached async sample only while it is this
// fresh; a failed refresh past this age drops the BME680 fields from the
// packet rather than freezing the last reading on the wire
static constexpr uint32_t SAMPLE_FRESH_MS = 2 * 60 * 1000;
// A heater-unstable cycle reports gas_resistance 0; carry the previous IAQ
// through such blips, but not forever
static constexpr uint32_t IAQ_CARRY_MS = 10 * 60 * 1000;
static constexpr uint32_t STATE_SAVE_PERIOD_MS = 6 * 60 * 60 * 1000;
static constexpr uint32_t STATE_SAVE_PERIOD_SECS = STATE_SAVE_PERIOD_MS / 1000;
static constexpr const char *stateFileName = "/prefs/bme680.dat";
static constexpr const char *legacyBsecStateFileName = "/prefs/bsec.dat"; // left behind by pre-open-IAQ firmware
// Async sampling state (driven from runOnce)
bool readingInFlight = false;
uint32_t readingDoneAtMs = 0;
// Cached last sample
bool haveSample = false;
uint32_t lastSampleMs = 0;
float lastTemperature = 0;
float lastHumidity = 0;
float lastPressureHPa = 0;
float lastGasOhms = 0;
uint16_t lastIaq = 0;
bool lastIaqValid = false;
uint32_t lastIaqMs = 0;
// Persistence bookkeeping: burn-in progress is saved whenever it advances
// (bounded to ~33 writes lifetime), steady-state saves are RTC-gated so a
// deep-sleeping node doesn't rewrite flash on every wake
uint32_t lastPersistedSampleCount = UINT32_MAX;
uint32_t lastPersistedWarmup = UINT32_MAX;
uint32_t lastSaveEpochSecs = 0;
uint32_t lastStateSaveMs = 0;
void captureSample();
void loadState();
void updateState();
void checkStatus(const char *functionName);
#endif
void maybeSaveState();
void saveState();
public:
BME680Sensor();
#if __has_include(<bsec2.h>)
virtual int32_t runOnce() override;
#endif
virtual bool getMetrics(meshtastic_Telemetry *measurement) override;
virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override;
};
#endif
#endif
+352
View File
@@ -0,0 +1,352 @@
#include "MeshTypes.h"
#include "TestUtil.h"
#include <unity.h>
#include "modules/Telemetry/Sensor/BME680IaqEstimator.h"
#include <cmath>
#include <cstdio>
#include <cstring>
// The estimator is pure math with no platform dependencies, so this suite has
// no feature guard: it runs everywhere the native tests run.
namespace
{
constexpr float CLEAN_GAS = 400000.0f; // ~clean-air gas resistance in Ohms
constexpr float REF_RH = 40.0f;
// Total update() calls before the first IAQ value can appear: the warm-up
// discards plus the burn-in history requirement
constexpr uint32_t CALLS_TO_READY = BME680IaqEstimator::WARMUP_DISCARD + BME680IaqEstimator::BURN_IN_SAMPLES;
/// Feed constant clean air until the estimator reports; returns the first IAQ
uint16_t makeReady(BME680IaqEstimator &est, float gasOhms = CLEAN_GAS, float rh = REF_RH)
{
uint16_t iaq = 0xFFFF;
for (uint32_t i = 0; i < CALLS_TO_READY; i++) {
bool got = est.update(gasOhms, rh, &iaq);
TEST_ASSERT_EQUAL_MESSAGE(i == CALLS_TO_READY - 1, got, "IAQ must appear exactly when burn-in completes");
}
return iaq;
}
/// On-disk hash contract (xor of the five words preceding xorHash), replicated
/// so corruption tests can forge otherwise-consistent state
uint32_t stateHash(const BME680IaqState &s)
{
uint32_t words[5];
memcpy(words, &s, sizeof(words));
return words[0] ^ words[1] ^ words[2] ^ words[3] ^ words[4];
}
} // namespace
void setUp(void) {}
void tearDown(void) {}
// --- Input validation ---
void test_rejects_invalid_gas()
{
BME680IaqEstimator est;
uint16_t iaq;
TEST_ASSERT_FALSE(est.update(0.0f, REF_RH, &iaq));
TEST_ASSERT_FALSE(est.update(-5000.0f, REF_RH, &iaq));
TEST_ASSERT_FALSE(est.update(NAN, REF_RH, &iaq));
TEST_ASSERT_FALSE(est.update(INFINITY, REF_RH, &iaq));
// Invalid samples must not consume warm-up or burn-in progress
makeReady(est);
}
void test_invalid_humidity_is_neutral()
{
BME680IaqEstimator est;
makeReady(est);
uint16_t iaq = 0xFFFF;
TEST_ASSERT_TRUE(est.update(CLEAN_GAS, NAN, &iaq));
TEST_ASSERT_EQUAL_UINT16(0, iaq);
// The fallback must not have moved the ceiling: a subsequent valid sample
// at the reference RH must still score 0 (catches a wrong fallback value,
// which would poison the baseline upward via ALPHA_UP)
TEST_ASSERT_TRUE(est.update(CLEAN_GAS, REF_RH, &iaq));
TEST_ASSERT_EQUAL_UINT16(0, iaq);
}
// --- Warm-up / burn-in gating ---
void test_no_output_until_burn_in()
{
BME680IaqEstimator est;
uint16_t iaq = 0xFFFF;
for (uint32_t i = 0; i < CALLS_TO_READY - 1; i++)
TEST_ASSERT_FALSE(est.update(CLEAN_GAS, REF_RH, &iaq));
TEST_ASSERT_FALSE(est.ready());
TEST_ASSERT_TRUE(est.update(CLEAN_GAS, REF_RH, &iaq));
TEST_ASSERT_TRUE(est.ready());
TEST_ASSERT_EQUAL_UINT16(0, iaq);
}
// --- Scoring ---
void test_clean_air_scores_zero()
{
BME680IaqEstimator est;
TEST_ASSERT_EQUAL_UINT16(0, makeReady(est));
}
void test_band_mapping_from_baseline_ratio()
{
// Gas dropping to 1/N of the clean baseline should land in the UI band
// the design targets: 1.31x ~Good, 1.7x ~Moderate/Poor edge, 3x ~beep
// threshold, 15x+ pegged at 500
struct {
float ratio;
uint16_t expected;
uint16_t tolerance;
} cases[] = {
{1.31f, 50, 6}, {1.7f, 98, 7}, {3.0f, 203, 8}, {15.0f, 499, 2}, {100.0f, 500, 1},
};
for (auto &c : cases) {
BME680IaqEstimator est;
makeReady(est);
uint16_t iaq = 0;
TEST_ASSERT_TRUE(est.update(CLEAN_GAS / c.ratio, REF_RH, &iaq));
char msg[64];
snprintf(msg, sizeof(msg), "ratio %.2f -> iaq %u", (double)c.ratio, iaq);
TEST_ASSERT_UINT_WITHIN_MESSAGE(c.tolerance, c.expected, iaq, msg);
}
}
void test_band_mapping_holds_for_high_resistance_sensors()
{
// Fresh/very clean sensors legitimately read in the MOhm range; the
// sanity clamp must not compress events there (regression: LN_CEIL_MAX
// was once ln(~730k), blinding the estimator above that)
BME680IaqEstimator est;
TEST_ASSERT_EQUAL_UINT16(0, makeReady(est, 5000000.0f));
uint16_t iaq = 0;
TEST_ASSERT_TRUE(est.update(5000000.0f / 3.0f, REF_RH, &iaq));
TEST_ASSERT_UINT_WITHIN(8, 203, iaq);
}
void test_floor_clamps_bound_extreme_pollution()
{
// Baseline seeded from heavily polluted air is clamped up to LN_FLOOR...
BME680IaqEstimator est;
uint16_t iaq = 0xFFFF;
for (uint32_t i = 0; i < CALLS_TO_READY; i++)
est.update(1000.0f, REF_RH, &iaq);
// ...so 1 kOhm scores as polluted relative to that floor, not as "normal"
TEST_ASSERT_TRUE(est.update(1000.0f, REF_RH, &iaq));
TEST_ASSERT_UINT_WITHIN(10, 297, iaq); // (ln(5000) - ln(1000)) / ln(15) * 500 = (8.517 - 6.908) / 2.708 * 500
// gas at the floor itself reads clean
TEST_ASSERT_TRUE(est.update(5000.0f, REF_RH, &iaq));
TEST_ASSERT_EQUAL_UINT16(0, iaq);
// absurdly low readings rail at exactly 500 via the sample clamp
TEST_ASSERT_TRUE(est.update(1.0f, REF_RH, &iaq));
TEST_ASSERT_EQUAL_UINT16(500, iaq);
}
void test_humidity_comfort_penalty()
{
// Present the same compensated log-resistance at 80 %RH: gas score stays
// ~0, and only the outside-the-30-60-deadband humidity penalty remains
BME680IaqEstimator est;
makeReady(est);
float gasAt80 = CLEAN_GAS * expf(-BME680IaqEstimator::KH * (80.0f - REF_RH));
uint16_t iaq = 0xFFFF;
TEST_ASSERT_TRUE(est.update(gasAt80, 80.0f, &iaq));
TEST_ASSERT_UINT_WITHIN(8, 38, iaq); // 0.15 * (20/40 * 500) = 37.5
// The dry side of the deadband penalizes symmetrically
BME680IaqEstimator estDry;
makeReady(estDry);
float gasAt10 = CLEAN_GAS * expf(-BME680IaqEstimator::KH * (10.0f - REF_RH));
TEST_ASSERT_TRUE(estDry.update(gasAt10, 10.0f, &iaq));
TEST_ASSERT_UINT_WITHIN(8, 38, iaq);
// Inside the deadband there is no penalty at all
BME680IaqEstimator est2;
makeReady(est2);
float gasAt55 = CLEAN_GAS * expf(-BME680IaqEstimator::KH * (55.0f - REF_RH));
TEST_ASSERT_TRUE(est2.update(gasAt55, 55.0f, &iaq));
TEST_ASSERT_EQUAL_UINT16(0, iaq);
}
// --- Baseline dynamics ---
void test_baseline_resists_sustained_pollution()
{
BME680IaqEstimator est;
makeReady(est);
uint16_t iaq = 0;
for (int i = 0; i < 10; i++) {
TEST_ASSERT_TRUE(est.update(100000.0f, REF_RH, &iaq));
TEST_ASSERT_GREATER_THAN_UINT(200, iaq); // ln(4) -> ~256, must stay "bad"
}
// Back to clean air: the ceiling barely decayed, so the score snaps to 0
TEST_ASSERT_TRUE(est.update(CLEAN_GAS, REF_RH, &iaq));
TEST_ASSERT_EQUAL_UINT16(0, iaq);
}
void test_baseline_rises_fast_toward_cleaner_air()
{
BME680IaqEstimator est;
makeReady(est, 300000.0f);
uint16_t iaq = 0xFFFF;
// Cleaner air scores 0 immediately and re-baselines within ~20 samples
for (int i = 0; i < 20; i++) {
TEST_ASSERT_TRUE(est.update(CLEAN_GAS, REF_RH, &iaq));
TEST_ASSERT_EQUAL_UINT16(0, iaq);
}
// The old air now reads as polluted relative to the new baseline
TEST_ASSERT_TRUE(est.update(300000.0f, REF_RH, &iaq));
TEST_ASSERT_UINT_WITHIN(8, 53, iaq); // ln(400/300)/ln(15) * 500
}
// --- Persistence ---
void test_serialize_restore_roundtrip()
{
BME680IaqEstimator est;
makeReady(est);
BME680IaqState state;
est.serialize(&state, 1000000);
TEST_ASSERT_EQUAL_UINT32(BME680IaqEstimator::MAGIC, state.magic);
TEST_ASSERT_EQUAL_UINT32(stateHash(state), state.xorHash);
TEST_ASSERT_EQUAL_UINT8(0, state.warmupRemaining);
// Warm-up progress travels with the state: a restored estimator reports
// on its very first sample (essential for one-sample-per-wake nodes)
BME680IaqEstimator restored;
TEST_ASSERT_TRUE(restored.restore(state, 1000000 + 3600));
uint16_t iaq = 0;
TEST_ASSERT_TRUE(restored.update(CLEAN_GAS / 3.0f, REF_RH, &iaq));
TEST_ASSERT_UINT_WITHIN(8, 203, iaq);
}
void test_restore_mid_burn_in_continues_progress()
{
BME680IaqEstimator est;
uint16_t iaq;
for (uint32_t i = 0; i < BME680IaqEstimator::WARMUP_DISCARD + 5; i++)
est.update(CLEAN_GAS, REF_RH, &iaq);
BME680IaqState state;
est.serialize(&state, 0);
BME680IaqEstimator restored;
TEST_ASSERT_TRUE(restored.restore(state, 0));
int producedAt = -1;
for (int i = 1; i <= 40; i++) {
if (restored.update(CLEAN_GAS, REF_RH, &iaq)) {
producedAt = i;
break;
}
}
// 5 of 30 burn-in samples were banked before the "reboot"
TEST_ASSERT_EQUAL_INT(BME680IaqEstimator::BURN_IN_SAMPLES - 5, producedAt);
}
void test_deep_sleep_node_converges_across_reboots()
{
// Simulate a power-saving SENSOR role: one sample per wake, RAM wiped
// between wakes, state restored+persisted each cycle. Must produce IAQ
// after exactly warm-up + burn-in wakes, not never.
BME680IaqState state;
bool haveState = false;
uint16_t iaq = 0xFFFF;
int producedAt = -1;
for (int wake = 1; wake <= 50; wake++) {
BME680IaqEstimator est;
if (haveState)
TEST_ASSERT_TRUE_MESSAGE(est.restore(state, 0), "persisted progress must restore on every wake");
if (est.update(CLEAN_GAS, REF_RH, &iaq)) {
producedAt = wake;
break;
}
est.serialize(&state, 0);
haveState = true;
}
TEST_ASSERT_EQUAL_INT((int)CALLS_TO_READY, producedAt);
TEST_ASSERT_EQUAL_UINT16(0, iaq);
}
void test_restore_rejects_corruption()
{
BME680IaqEstimator est;
makeReady(est);
BME680IaqState good;
est.serialize(&good, 1000000);
BME680IaqEstimator target;
BME680IaqState bad = good;
bad.magic ^= 1;
TEST_ASSERT_FALSE(target.restore(bad, 1000000));
bad = good;
bad.version = BME680IaqEstimator::VERSION + 1;
bad.xorHash = stateHash(bad);
TEST_ASSERT_FALSE(target.restore(bad, 1000000));
bad = good;
bad.xorHash ^= 0xDEADBEEF;
TEST_ASSERT_FALSE(target.restore(bad, 1000000));
// Consistent hash but implausible ceiling (the ceiling check only applies
// once samples have been accepted)
bad = good;
bad.lnCeiling = 20.0f;
bad.xorHash = stateHash(bad);
TEST_ASSERT_FALSE(target.restore(bad, 1000000));
bad = good;
bad.lnCeiling = NAN;
bad.xorHash = stateHash(bad);
TEST_ASSERT_FALSE(target.restore(bad, 1000000));
}
void test_restore_staleness()
{
BME680IaqEstimator est;
makeReady(est);
BME680IaqState state;
est.serialize(&state, 1000000);
BME680IaqEstimator target;
TEST_ASSERT_FALSE(target.restore(state, 1000000 + BME680IaqEstimator::STATE_MAX_AGE_SECS + 1));
TEST_ASSERT_TRUE(target.restore(state, 1000000 + BME680IaqEstimator::STATE_MAX_AGE_SECS - 1));
// Unknown age (no RTC at save time or now) is accepted rather than discarded
est.serialize(&state, 0);
BME680IaqEstimator target2;
TEST_ASSERT_TRUE(target2.restore(state, 2000000));
est.serialize(&state, 1000000);
BME680IaqEstimator target3;
TEST_ASSERT_TRUE(target3.restore(state, 0));
}
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
printf("\n=== BME680 IAQ estimator ===\n");
RUN_TEST(test_rejects_invalid_gas);
RUN_TEST(test_invalid_humidity_is_neutral);
RUN_TEST(test_no_output_until_burn_in);
RUN_TEST(test_clean_air_scores_zero);
RUN_TEST(test_band_mapping_from_baseline_ratio);
RUN_TEST(test_band_mapping_holds_for_high_resistance_sensors);
RUN_TEST(test_floor_clamps_bound_extreme_pollution);
RUN_TEST(test_humidity_comfort_penalty);
RUN_TEST(test_baseline_resists_sustained_pollution);
RUN_TEST(test_baseline_rises_fast_toward_cleaner_air);
RUN_TEST(test_serialize_restore_roundtrip);
RUN_TEST(test_restore_mid_burn_in_continues_progress);
RUN_TEST(test_deep_sleep_node_converges_across_reboots);
RUN_TEST(test_restore_staleness);
RUN_TEST(test_restore_rejects_corruption);
exit(UNITY_END());
}
void loop() {}
+3 -3
View File
@@ -44,15 +44,15 @@ custom_sdkconfig =
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP=y
; Override lib_deps to use environmental_extra_no_bsec instead of environmental_extra
; BSEC library uses ~3.5KB DRAM which causes overflow on original ESP32 targets
; Overrides esp32_common's lib_deps: adds networking_extra and omits
; esp32_https_server (mesh/http is excluded from this target's build_src_filter)
lib_deps =
${arduino_base.lib_deps}
${networking_base.lib_deps}
${networking_extra.lib_deps}
${radiolib_base.lib_deps}
${environmental_base.lib_deps}
${environmental_extra_no_bsec.lib_deps}
${environmental_extra.lib_deps}
# TODO renovate
https://github.com/mverch67/libpax/archive/6f52ee989301cdabaeef00bcbf93bff55708ce2f.zip
# renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib
-1
View File
@@ -93,7 +93,6 @@ lib_ignore =
${esp32_common.lib_ignore}
libpax
esp8266-oled-ssd1306
bsec2
esp32_idf5_https_server
esp_driver_cam
esp_http_server
@@ -18,7 +18,6 @@ build_flags =
-DELECROW_ThinkNode_M3
-DGPS_POWER_TOGGLE
-D CONFIG_NFCT_PINS_AS_GPIOS=1
-L "${platformio.libdeps_dir}/${this.__env__}/bsec2/src/cortex-m4/fpv4-sp-d16-hard"
build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/ELECROW-ThinkNode-M3>
lib_deps =
${nrf52840_base.lib_deps}
@@ -20,18 +20,6 @@ build_flags = ${nrf52840_base.build_flags}
build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/diy/nrf52_promicro_diy_tcxo>
debug_tool = jlink
; TEMPORARY: drop BSEC2 + its BME68x driver. This image is ~2.3 KB OVER the 0xEA000
; warm-store cap and has been failing the nrf52_warm_region guard on develop since
; 2026-08-05. Unlike the RAK boards there is no Ethernet stack to reclaim here -- nrf52_base
; already filters mesh/eth, mesh/api and mesh/wifi, and HAS_ETHERNET defaults to 0 -- so the
; sensor library is what has to go. BME680Sensor is gated on __has_include(<bsec2.h>), so
; ignoring the libraries compiles it out. Revert once the environmental sensor roster is
; opt-in per board rather than linked into every target.
lib_ignore =
${nrf52_base.lib_ignore}
bsec2
BME68x Sensor library
; NRF52 ProMicro w/ E-Ink display
[env:nrf52_promicro_diy-inkhud]
board_level = extra
@@ -15,7 +15,6 @@ build_flags = ${nrf52840_base.build_flags}
-I variants/nrf52840/muzi_base
-D MUZI_BASE
-D CONFIG_NFCT_PINS_AS_GPIOS=1
-L "${platformio.libdeps_dir}/${this.__env__}/bsec2/src/cortex-m4/fpv4-sp-d16-hard"
build_src_filter = ${nrf52840_base.build_src_filter} +<../variants/nrf52840/muzi_base>
lib_deps =