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:
15 files changed
+927
-226
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
"description."
|
||||
],
|
||||
"rak4631": {
|
||||
"ram_bytes": 113000,
|
||||
"flash_bytes": 786000
|
||||
"ram_bytes": 108000,
|
||||
"flash_bytes": 746000
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
@@ -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() {}
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user