mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-17 09:01:30 +10:00
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:
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user