diff --git a/bin/bme680_iaq_replay.cpp b/bin/bme680_iaq_replay.cpp new file mode 100644 index 00000000000..62d0a5286e0 --- /dev/null +++ b/bin/bme680_iaq_replay.cpp @@ -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 +#include + +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; +} diff --git a/bin/ram_budgets.json b/bin/ram_budgets.json index b48903a0b82..a7e10125e66 100644 --- a/bin/ram_budgets.json +++ b/bin/ram_budgets.json @@ -18,7 +18,7 @@ "description." ], "rak4631": { - "ram_bytes": 113000, - "flash_bytes": 786000 + "ram_bytes": 108000, + "flash_bytes": 746000 } } diff --git a/docs/bme680_iaq_replay.md b/docs/bme680_iaq_replay.md new file mode 100644 index 00000000000..5fc8d96df33 --- /dev/null +++ b/docs/bme680_iaq_replay.md @@ -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. diff --git a/platformio.ini b/platformio.ini index 2c7973ecf31..7e05f10f0bd 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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 diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index aa985c90961..aae103e2430 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -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() || __has_include() +#if __has_include() #include "Sensor/BME680Sensor.h" #endif @@ -306,7 +306,7 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::LTR390UV); #endif -#if __has_include() || __has_include() +#if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::BME_680); #endif #if __has_include() @@ -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) diff --git a/src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp b/src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp new file mode 100644 index 00000000000..89a792df6d4 --- /dev/null +++ b/src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp @@ -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 +#include +#include + +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; +} diff --git a/src/modules/Telemetry/Sensor/BME680IaqEstimator.h b/src/modules/Telemetry/Sensor/BME680IaqEstimator.h new file mode 100644 index 00000000000..87f19243c9d --- /dev/null +++ b/src/modules/Telemetry/Sensor/BME680IaqEstimator.h @@ -0,0 +1,104 @@ +#pragma once + +#include + +/** + * 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; +}; diff --git a/src/modules/Telemetry/Sensor/BME680Sensor.cpp b/src/modules/Telemetry/Sensor/BME680Sensor.cpp index 107601267e8..9162a932120 100644 --- a/src/modules/Telemetry/Sensor/BME680Sensor.cpp +++ b/src/modules/Telemetry/Sensor/BME680Sensor.cpp @@ -1,58 +1,25 @@ #include "configuration.h" -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && (__has_include() || __has_include()) +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() #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() -#include -#endif +#include BME680Sensor::BME680Sensor() : TelemetrySensor(meshtastic_TelemetrySensorType_BME680, "BME680") {} -#if __has_include() -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() - 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; } -bool BME680Sensor::getMetrics(meshtastic_Telemetry *measurement) +int32_t BME680Sensor::runOnce() { -#if __has_include() - if (bme680.getData(BSEC_OUTPUT_RAW_PRESSURE).signal == 0) - return false; + uint32_t now = Time::getMillis(); - 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; + 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 (!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.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; + measurement->variant.environment_metrics.temperature = lastTemperature; + measurement->variant.environment_metrics.relative_humidity = lastHumidity; + measurement->variant.environment_metrics.barometric_pressure = lastPressureHPa; - // 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)) { + // 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; + } - 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() 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() { -#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); - } + 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 { - /* 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++; - } + // 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(); +} - 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); - } +void BME680Sensor::saveState() +{ +#ifdef FSCom + BME680IaqState state; + uint32_t nowSecs = getValidTime(RTCQuality::RTCQualityDevice); + iaqEstimator.serialize(&state, nowSecs); + + // 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 diff --git a/src/modules/Telemetry/Sensor/BME680Sensor.h b/src/modules/Telemetry/Sensor/BME680Sensor.h index b8c0bd81099..a10ea1fefca 100644 --- a/src/modules/Telemetry/Sensor/BME680Sensor.h +++ b/src/modules/Telemetry/Sensor/BME680Sensor.h @@ -1,66 +1,71 @@ #include "configuration.h" -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && (__has_include() || __has_include()) +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() #include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "BME680IaqEstimator.h" #include "TelemetrySensor.h" -#if __has_include() -#include -#include -#else #include #include -#endif - -#define STATE_SAVE_PERIOD UINT32_C(360 * 60 * 1000) // That's 6 hours worth of millis() -#if __has_include() -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 bme680; -#else using BME680Ptr = std::unique_ptr; static BME680Ptr makeBME680(TwoWire *bus) { return BME680Ptr(new Adafruit_BME680(bus)); } BME680Ptr bme680; -#endif + BME680IaqEstimator iaqEstimator; + + 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 - protected: -#if __has_include() - 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}; + // 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() virtual int32_t runOnce() override; -#endif virtual bool getMetrics(meshtastic_Telemetry *measurement) override; virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; }; -#endif \ No newline at end of file +#endif diff --git a/test/test_bme680_iaq/test_main.cpp b/test/test_bme680_iaq/test_main.cpp new file mode 100644 index 00000000000..d4844e872ef --- /dev/null +++ b/test/test_bme680_iaq/test_main.cpp @@ -0,0 +1,352 @@ +#include "MeshTypes.h" +#include "TestUtil.h" +#include + +#include "modules/Telemetry/Sensor/BME680IaqEstimator.h" +#include +#include +#include + +// 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() {} diff --git a/variants/esp32/esp32.ini b/variants/esp32/esp32.ini index 40d43dad9a8..1986c1a9aee 100644 --- a/variants/esp32/esp32.ini +++ b/variants/esp32/esp32.ini @@ -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 diff --git a/variants/esp32p4/esp32p4.ini b/variants/esp32p4/esp32p4.ini index 8a284162d29..a435fdfa725 100644 --- a/variants/esp32p4/esp32p4.ini +++ b/variants/esp32p4/esp32p4.ini @@ -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 diff --git a/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini b/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini index 1b36d2da937..2b6b9aabfc4 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini +++ b/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini @@ -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} diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini index dfbd918765a..a72b8c61e66 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini @@ -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(), 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 diff --git a/variants/nrf52840/muzi_base/platformio.ini b/variants/nrf52840/muzi_base/platformio.ini index 90c871c200c..3a249428185 100644 --- a/variants/nrf52840/muzi_base/platformio.ini +++ b/variants/nrf52840/muzi_base/platformio.ini @@ -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 =