Add SEN6x sensors (#11390)

* Add SEN6X

* Adds new SENXX class for SEN5X and SEN6X
* Adds CO2 sensor calibration class to be shared among othre CO2 sensors

* Make existing CO2 sensor draw from CO2Sensor class

* Minor coment for CO2 sensor class

* Move away from getRTC in SENXX class to keep track of time changes.

* Change all sensors to millis for tracking time, instead of using getRTC

* Add comments regarding VOC state

* Avoid storing non-valid RTC

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Avoid CO2 sensor warm-up time to be below PM measured started

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix limits in CO2 sensor calibration

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Add pragma once on headers

* Avoid non-working ASC commands

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix data poll

* Move pm measure started before warmup check

* Make cleaning non-blocking

* Restore previous state if cleaning fails. Fix data ready condition.

* Fix CO2 sensor checks for calibration

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
oscgonfer
2026-08-13 13:13:50 -04:00
committed by GitHub
co-authored by coderabbitai[bot]
parent 3b608b8fc5
commit 944fd26580
21 changed files with 2216 additions and 1313 deletions
+1
View File
@@ -300,6 +300,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#define BQ25896_ADDR 0x6B
#define LTR553ALS_ADDR 0x23
#define SEN5X_ADDR 0x69
#define SEN6X_ADDR 0x6B // same as QMI8658_ADDR and BQ25896_ADDR
#define SCD30_ADDR 0x61
#define ADS1X15_ADDR 0x48
#define ADS1X15_ADDR_ALT1 0x49
+2 -2
View File
@@ -50,8 +50,8 @@ ScanI2C::FoundDevice ScanI2C::firstMagnetometer() const
ScanI2C::FoundDevice ScanI2C::firstAQI() const
{
ScanI2C::DeviceType types[] = {PMSA003I, SEN5X, SCD4X, SFA30};
return firstOfOrNONE(4, types);
ScanI2C::DeviceType types[] = {PMSA003I, SEN5X, SEN6X, SCD4X, SFA30};
return firstOfOrNONE(5, types);
}
ScanI2C::FoundDevice ScanI2C::firstRGBLED() const
+1
View File
@@ -96,6 +96,7 @@ class ScanI2C
CST3530,
BMI270,
SEN5X,
SEN6X,
SFA30,
CW2015,
SCD30,
+15 -1
View File
@@ -160,12 +160,19 @@ bool ScanI2CTwoWire::i2cCommandResponseLength(ScanI2C::DeviceAddress addr, uint1
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
#include "../modules/Telemetry/Sensor/SEN5XSensor.h"
#include "../modules/Telemetry/Sensor/SEN6XSensor.h"
bool probeSEN5X(TwoWire *i2cBus, uint8_t address, ScanI2C::I2CPort port)
{
SEN5XSensor sen5xsensor;
return sen5xsensor.probe(i2cBus, address, port);
}
bool probeSEN6X(TwoWire *i2cBus, uint8_t address, ScanI2C::I2CPort port)
{
SEN6XSensor sen6xsensor;
return sen6xsensor.probe(i2cBus, address, port);
}
bool probeHM330x(TwoWire *i2cBus, uint8_t address)
{
@@ -700,7 +707,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
logFoundDevice("QMC6310U", (uint8_t)addr.address);
break;
case QMI8658_ADDR:
case QMI8658_ADDR: // same as BQ25896_ADDR and SEN6X_ADDR
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x0A), 1); // get ID
if (registerValue == 0xC0) {
type = BQ24295;
@@ -721,6 +728,13 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
type = ISM330DHCX;
logFoundDevice("ISM330DHCX", (uint8_t)addr.address);
} else {
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
if (probeSEN6X(i2cBus, addr.address, port)) {
type = SEN6X;
logFoundDevice("SEN6X", addr.address);
break;
}
#endif
type = QMI8658;
logFoundDevice("QMI8658", (uint8_t)addr.address);
}
@@ -27,6 +27,7 @@ static constexpr uint16_t TX_HISTORY_KEY_AIR_QUALITY_TELEMETRY = 0x8004;
#include "Sensor/AddI2CSensorTemplate.h"
#include "Sensor/PMSA003ISensor.h"
#include "Sensor/SEN5XSensor.h"
#include "Sensor/SEN6XSensor.h"
#if __has_include(<SensirionI2cScd4x.h>)
#include "Sensor/SCD4XSensor.h"
#endif
@@ -66,6 +67,8 @@ void AirQualityTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner)
supportedSensors[PMSA003I_ADDR] = ScanI2C::DeviceType::PMSA003I;
if (!supportedSensors.count(SEN5X_ADDR))
supportedSensors[SEN5X_ADDR] = ScanI2C::DeviceType::SEN5X;
if (!supportedSensors.count(SEN6X_ADDR))
supportedSensors[SEN6X_ADDR] = ScanI2C::DeviceType::SEN6X;
#if __has_include(<SensirionI2cScd4x.h>)
if (!supportedSensors.count(SCD4X_ADDR))
supportedSensors[SCD4X_ADDR] = ScanI2C::DeviceType::SCD4X;
@@ -108,6 +111,7 @@ void AirQualityTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner)
// order by priority of metrics/values (low top, high bottom)
addSensor<PMSA003ISensor>(i2cScanner, ScanI2C::DeviceType::PMSA003I);
addSensor<SEN5XSensor>(i2cScanner, ScanI2C::DeviceType::SEN5X);
addSensor<SEN6XSensor>(i2cScanner, ScanI2C::DeviceType::SEN6X);
#if __has_include(<SensirionI2cScd4x.h>)
addSensor<SCD4XSensor>(i2cScanner, ScanI2C::DeviceType::SCD4X);
#endif
+111
View File
@@ -0,0 +1,111 @@
#pragma once
#include "MeshModule.h"
/*
Shared CO2 calibration interface + admin-message dispatch for any sensor that
exposes Sensirion-style CO2 auto/forced calibration: automatic self-calibration
(ASC), forced recalibration (FRC), altitude/ambient-pressure compensation, and
a calibration-history factory reset. SCD4XSensor, SCD30Sensor and the
CO2-capable SEN6X variants (SEN63C/SEN66/SEN69C, via SENXXSensor) all implement
this instead of duplicating the same admin-message branching logic.
Concrete classes only need to implement the low-level co2* operations against
their own I2C command set; handleCo2AdminRequest() below is the one shared
place that decides *when* to call FRC vs ASC, validates that a target CO2 was
supplied for FRC, and reverts ASC on a failed FRC attempt.
*/
class CO2CalibrationSensor
{
protected:
virtual ~CO2CalibrationSensor() {}
// Forced recalibration against a known reference CO2 concentration (ppm).
virtual bool co2PerformFRC(uint32_t targetCO2ppm) = 0;
// Automatic self-calibration on/off.
virtual bool co2GetASC(bool &ascEnabled) = 0;
virtual bool co2SetASC(bool ascEnabled) = 0;
// Optional: not every sensor exposes a settable ASC baseline (e.g. SCD30/SEN6X don't).
virtual bool co2SetASCBaseline(uint32_t targetCO2ppm) { return true; }
// Altitude/pressure compensation. altitude in meters above sea level,
// ambientPressure in Pa (implementations convert to whatever unit their
// own command set expects).
virtual bool co2SetAltitude(uint32_t altitude) = 0;
virtual bool co2SetAmbientPressure(uint32_t ambientPressurePa) { return false; }
// Erases the sensor's FRC/ASC calibration history. Optional.
virtual bool co2FactoryReset() { return false; }
// Snapshot of whichever *_config admin message fields were populated,
// translated once by the caller into this sensor-agnostic shape.
struct Co2AdminRequest {
bool hasFactoryReset = false;
bool hasSetAsc = false;
bool setAsc = false;
bool hasTargetCo2 = false;
uint32_t targetCo2 = 0;
bool hasSetAltitude = false;
uint32_t setAltitude = 0;
bool hasSetAmbientPressure = false;
uint32_t setAmbientPressure = 0;
};
// Returns false if a requested operation failed - callers should map
// that to AdminMessageHandleResult::NOT_HANDLED like they already do for
// their sensor-specific fields (e.g. temperature offset, power mode).
bool handleCo2AdminRequest(const Co2AdminRequest &cfg, const char *sensorName)
{
if (cfg.hasFactoryReset) {
LOG_DEBUG("%s: Requested CO2 calibration factory reset", sensorName);
return co2FactoryReset();
}
if (cfg.hasSetAsc) {
if (!cfg.setAsc) {
bool currentASC = false;
if (!co2GetASC(currentASC)) {
return false;
}
// Disabling ASC is how you request a forced recalibration (FRC).
if (!cfg.hasTargetCo2) {
LOG_ERROR("%s: target CO2 not provided for FRC", sensorName);
return false;
}
LOG_DEBUG("%s: Request for FRC", sensorName);
if (!co2SetASC(false)) {
return false;
}
if (!co2PerformFRC(cfg.targetCo2)) {
// Restore previous ASC state since the FRC attempt failed.
co2SetASC(currentASC);
return false;
}
} else {
LOG_DEBUG("%s: Request for ASC", sensorName);
if (!co2SetASC(true)) {
return false;
}
// ASC with target CO2 is only available in SCD4X
if (cfg.hasTargetCo2) {
if (!co2SetASCBaseline(cfg.targetCo2)) {
return false;
}
}
}
}
if (cfg.hasSetAltitude) {
if (!co2SetAltitude(cfg.setAltitude)) {
return false;
}
} else if (cfg.hasSetAmbientPressure) {
if (!co2SetAmbientPressure(cfg.setAmbientPressure)) {
return false;
}
}
return true;
}
};
@@ -42,7 +42,7 @@ bool HM330XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev)
uint32_t HM330XSensor::wakeUp()
{
state = State::ACTIVE;
measureStarted = getTime();
measureStarted = millis();
return HM330X_WARMUP_MS;
}
@@ -64,9 +64,7 @@ bool HM330XSensor::isActive()
int32_t HM330XSensor::pendingForReadyMs()
{
uint32_t now;
now = getTime();
uint32_t sincePMMeasureStarted = (now - measureStarted) * 1000;
uint32_t sincePMMeasureStarted = millis() - measureStarted;
LOG_DEBUG("%s: Since measure started: %ums", sensorName, sincePMMeasureStarted);
if (sincePMMeasureStarted < HM330X_WARMUP_MS) {
@@ -18,6 +18,8 @@ class HM330XSensor : public TelemetrySensor
private:
enum class State { IDLE, ACTIVE };
State state = State::IDLE;
// millis()-based, not wall-clock: this only measures in-session warmup elapsed time,
// and getTime() can jump discontinuously when RTC quality improves mid-session.
uint32_t measureStarted = 0;
uint8_t buffer[HM330X_FRAME_LENGTH]{};
TwoWire *_bus{};
@@ -157,9 +157,7 @@ int32_t PMSA003ISensor::wakeUpTimeMs()
int32_t PMSA003ISensor::pendingForReadyMs()
{
#ifdef PMSA003I_ENABLE_PIN
uint32_t now;
now = getTime();
uint32_t sincePmMeasureStarted = (now - pmMeasureStarted) * 1000;
uint32_t sincePmMeasureStarted = millis() - pmMeasureStarted;
LOG_DEBUG("%s: Since measure started: %ums", sensorName, sincePmMeasureStarted);
if (sincePmMeasureStarted < PMSA003I_WARMUP_MS) {
@@ -195,7 +193,7 @@ uint32_t PMSA003ISensor::wakeUp()
LOG_INFO("%s Waking", sensorName);
digitalWrite(PMSA003I_ENABLE_PIN, HIGH);
state = PMSA003I_ACTIVE;
pmMeasureStarted = getTime();
pmMeasureStarted = millis();
return PMSA003I_WARMUP_MS;
#endif
@@ -39,6 +39,8 @@ class PMSA003ISensor : public TelemetrySensor
uint16_t computedChecksum = 0;
uint16_t receivedChecksum = 0;
// millis()-based, not wall-clock: this only measures in-session warmup elapsed time,
// and getTime() can jump discontinuously when RTC quality improves mid-session.
uint32_t pmMeasureStarted = 0;
uint8_t buffer[PMSA003I_FRAME_LENGTH]{};
+18 -21
View File
@@ -424,37 +424,34 @@ AdminMessageHandleResult SCD30Sensor::handleAdminMessage(const meshtastic_MeshPa
LOG_DEBUG("%s: Requested soft reset", sensorName);
this->softReset();
} else {
const auto &cfg = request->sensor_config.scd30_config;
if (request->sensor_config.scd30_config.has_set_asc) {
this->setASC(request->sensor_config.scd30_config.set_asc);
if (request->sensor_config.scd30_config.set_asc == false) {
LOG_DEBUG("%s: Request for FRC", sensorName);
if (request->sensor_config.scd30_config.has_set_target_co2_conc) {
this->performFRC(request->sensor_config.scd30_config.set_target_co2_conc);
} else {
// FRC requested but no target CO2 provided
LOG_ERROR("%s: target CO2 not provided", sensorName);
result = AdminMessageHandleResult::NOT_HANDLED;
break;
}
// ASC/FRC/altitude calibration branching is shared with SCD4XSensor and the
// CO2-capable SEN6X variants via CO2CalibrationSensor.
if (cfg.has_set_asc || cfg.has_set_altitude) {
Co2AdminRequest co2req;
co2req.hasSetAsc = cfg.has_set_asc;
co2req.setAsc = cfg.set_asc;
co2req.hasTargetCo2 = cfg.has_set_target_co2_conc;
co2req.targetCo2 = cfg.set_target_co2_conc;
co2req.hasSetAltitude = cfg.has_set_altitude;
co2req.setAltitude = cfg.set_altitude;
if (!this->handleCo2AdminRequest(co2req, sensorName)) {
result = AdminMessageHandleResult::NOT_HANDLED;
break;
}
}
// Check for temperature offset
// NOTE: this requires to have a sensor working on stable environment
// And to make it between readings
if (request->sensor_config.scd30_config.has_set_temperature) {
this->setTemperature(request->sensor_config.scd30_config.set_temperature);
}
// Check for altitude
if (request->sensor_config.scd30_config.has_set_altitude) {
this->setAltitude(request->sensor_config.scd30_config.set_altitude);
if (cfg.has_set_temperature) {
this->setTemperature(cfg.set_temperature);
}
// Check for set measuremen interval
if (request->sensor_config.scd30_config.has_set_measurement_interval) {
this->setMeasurementInterval(request->sensor_config.scd30_config.set_measurement_interval);
if (cfg.has_set_measurement_interval) {
this->setMeasurementInterval(cfg.set_measurement_interval);
}
}
+23 -1
View File
@@ -4,12 +4,13 @@
#include "../detect/ReClockI2C.h"
#include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "CO2Sensor.h"
#include "TelemetrySensor.h"
#include <SensirionI2cScd30.h>
#define SCD30_I2C_CLOCK_SPEED 100000
class SCD30Sensor : public TelemetrySensor
class SCD30Sensor : public TelemetrySensor, public CO2CalibrationSensor
{
private:
SensirionI2cScd30 scd30;
@@ -29,6 +30,27 @@ class SCD30Sensor : public TelemetrySensor
bool startMeasurement();
bool stopMeasurement();
// CO2CalibrationSensor overrides - thin wrappers, shared with SCD4XSensor and
// the CO2-capable SEN6X variants via CO2CalibrationSensor::handleCo2AdminRequest().
// SCD30 has no ambient-pressure command or calibration-history factory reset, so
// those two are left at CO2CalibrationSensor's default (unsupported) implementation.
bool co2PerformFRC(uint32_t targetCO2ppm) override
{
return targetCO2ppm <= UINT16_MAX && performFRC(static_cast<uint16_t>(targetCO2ppm));
}
bool co2GetASC(bool &ascEnabled) override
{
uint16_t v = 0;
bool ok = getASC(v);
ascEnabled = v != 0;
return ok;
}
bool co2SetASC(bool ascEnabled) override { return setASC(ascEnabled); }
bool co2SetAltitude(uint32_t altitude) override
{
return altitude <= UINT16_MAX && setAltitude(static_cast<uint16_t>(altitude));
}
// Parameters
uint16_t ascActive = 1;
uint16_t measurementInterval = 2;
+41 -80
View File
@@ -705,7 +705,7 @@ uint32_t SCD4XSensor::wakeUp()
#endif /* SCD4X_I2C_CLOCK_SPEED */
if (startMeasurement()) {
co2MeasureStarted = getTime();
co2MeasureStarted = millis();
#ifdef SCD4X_I2C_CLOCK_SPEED
reClockI2C.restoreClock();
#endif /* SCD4X_I2C_CLOCK_SPEED */
@@ -755,9 +755,7 @@ int32_t SCD4XSensor::wakeUpTimeMs()
int32_t SCD4XSensor::pendingForReadyMs()
{
uint32_t now;
now = getTime();
uint32_t sinceCO2MeasureStarted = (now - co2MeasureStarted) * 1000;
uint32_t sinceCO2MeasureStarted = millis() - co2MeasureStarted;
LOG_DEBUG("%s: Since measure started: %ums", sensorName, sinceCO2MeasureStarted);
if (sinceCO2MeasureStarted < SCD4X_WARMUP_MS) {
@@ -785,85 +783,48 @@ AdminMessageHandleResult SCD4XSensor::handleAdminMessage(const meshtastic_MeshPa
break;
}
if (request->sensor_config.scd4x_config.has_factory_reset) {
LOG_DEBUG("%s: Requested factory reset", sensorName);
if (!this->factoryReset()) {
{
const auto &cfg = request->sensor_config.scd4x_config;
bool ok = true;
// FRC/ASC/altitude/pressure/factory-reset calibration branching is shared with
// SCD30Sensor and the CO2-capable SEN6X variants via CO2CalibrationSensor.
if (cfg.has_factory_reset || cfg.has_set_asc || cfg.has_set_altitude || cfg.has_set_ambient_pressure) {
Co2AdminRequest co2req;
co2req.hasFactoryReset = cfg.has_factory_reset;
co2req.hasSetAsc = cfg.has_set_asc;
co2req.setAsc = cfg.set_asc;
co2req.hasTargetCo2 = cfg.has_set_target_co2_conc;
co2req.targetCo2 = cfg.set_target_co2_conc;
co2req.hasSetAltitude = cfg.has_set_altitude;
co2req.setAltitude = cfg.set_altitude;
co2req.hasSetAmbientPressure = cfg.has_set_ambient_pressure;
co2req.setAmbientPressure = cfg.set_ambient_pressure;
ok &= this->handleCo2AdminRequest(co2req, sensorName);
}
// A factory reset erases calibration history outright - matches the original
// behavior of skipping every other field when it's requested.
if (ok && !cfg.has_factory_reset) {
// Check for temperature offset
// NOTE: this requires to have a sensor working on stable environment
// And to make it between readings
if (cfg.has_set_temperature) {
ok &= this->setTemperature(cfg.set_temperature);
}
// Check for low power mode
// NOTE: to switch from one mode to another do:
// setPowerMode -> startMeasurement
if (cfg.has_set_power_mode) {
ok &= this->setPowerMode(cfg.set_power_mode);
}
}
if (!ok) {
result = AdminMessageHandleResult::NOT_HANDLED;
break;
}
} else {
if (request->sensor_config.scd4x_config.has_set_asc) {
getASC(ascActive);
bool currentASC = ascActive;
if (request->sensor_config.scd4x_config.set_asc == false) {
LOG_DEBUG("%s: Request for FRC", sensorName);
if (request->sensor_config.scd4x_config.has_set_target_co2_conc) {
if (this->setASC(request->sensor_config.scd4x_config.set_asc)) {
if (!this->performFRC(request->sensor_config.scd4x_config.set_target_co2_conc)) {
result = AdminMessageHandleResult::NOT_HANDLED;
// Set it back to ASC if failed
setASC(currentASC);
break;
};
} else {
result = AdminMessageHandleResult::NOT_HANDLED;
break;
}
} else {
// FRC requested but no target CO2 provided
LOG_ERROR("%s: target CO2 not provided", sensorName);
result = AdminMessageHandleResult::NOT_HANDLED;
break;
}
} else {
LOG_DEBUG("%s: Request for ASC", sensorName);
if (this->setASC(request->sensor_config.scd4x_config.set_asc)) {
if (request->sensor_config.scd4x_config.has_set_target_co2_conc) {
LOG_DEBUG("%s: Request has target CO2", sensorName);
this->setASCBaseline(request->sensor_config.scd4x_config.set_target_co2_conc);
// NOTE - in this situation, if we set ASC, but baseline set fails, we stay on ASC
} else {
LOG_DEBUG("%s: Request doesn't have target CO2", sensorName);
}
} else {
result = AdminMessageHandleResult::NOT_HANDLED;
break;
}
}
}
// Check for temperature offset
// NOTE: this requires to have a sensor working on stable environment
// And to make it between readings
if (request->sensor_config.scd4x_config.has_set_temperature) {
if (!this->setTemperature(request->sensor_config.scd4x_config.set_temperature)) {
result = AdminMessageHandleResult::NOT_HANDLED;
break;
}
}
// Check for altitude or pressure offset
if (request->sensor_config.scd4x_config.has_set_altitude) {
if (!this->setAltitude(request->sensor_config.scd4x_config.set_altitude)) {
result = AdminMessageHandleResult::NOT_HANDLED;
break;
}
} else if (request->sensor_config.scd4x_config.has_set_ambient_pressure) {
if (!this->setAmbientPressure(request->sensor_config.scd4x_config.set_ambient_pressure)) {
result = AdminMessageHandleResult::NOT_HANDLED;
break;
}
}
// Check for low power mode
// NOTE: to switch from one mode to another do:
// setPowerMode -> startMeasurement
if (request->sensor_config.scd4x_config.has_set_power_mode) {
if (!this->setPowerMode(request->sensor_config.scd4x_config.set_power_mode)) {
result = AdminMessageHandleResult::NOT_HANDLED;
break;
}
}
}
result = AdminMessageHandleResult::HANDLED;
+37 -1
View File
@@ -4,6 +4,7 @@
#include "../detect/ReClockI2C.h"
#include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "CO2Sensor.h"
#include "TelemetrySensor.h"
#include "gps/RTC.h"
#include <SensirionI2cScd4x.h>
@@ -13,7 +14,7 @@
#define SCD4X_WARMUP_MS 5000
#define SCD4X_MAX_RETRIES 3
class SCD4XSensor : public TelemetrySensor
class SCD4XSensor : public TelemetrySensor, public CO2CalibrationSensor
{
private:
SensirionI2cScd4x scd4x;
@@ -35,10 +36,45 @@ class SCD4XSensor : public TelemetrySensor
bool startMeasurement();
bool stopMeasurement();
// CO2CalibrationSensor overrides - thin wrappers around the methods above,
// shared with SCD30Sensor and the CO2-capable SEN6X variants via
// CO2CalibrationSensor::handleCo2AdminRequest().
bool co2PerformFRC(uint32_t targetCO2ppm) override
{
return targetCO2ppm <= UINT16_MAX && performFRC(static_cast<uint16_t>(targetCO2ppm));
}
bool co2GetASC(bool &ascEnabled) override
{
uint16_t v = 0;
bool ok = getASC(v);
ascEnabled = v != 0;
return ok;
}
bool co2SetASC(bool ascEnabled) override { return setASC(ascEnabled); }
bool co2SetASCBaseline(uint32_t targetCO2ppm) override
{
return targetCO2ppm <= UINT16_MAX && setASCBaseline(static_cast<uint16_t>(targetCO2ppm));
}
bool co2SetAltitude(uint32_t altitude) override
{
if (altitude > 3000)
return false;
return altitude <= UINT16_MAX && setAltitude(static_cast<uint16_t>(altitude));
}
bool co2SetAmbientPressure(uint32_t ambientPressurePa) override
{
if (ambientPressurePa < 70000 || ambientPressurePa > 120000)
return false;
return setAmbientPressure(ambientPressurePa);
}
bool co2FactoryReset() override { return factoryReset(); }
uint16_t ascActive = 1;
// low power measurement mode (on sensirion side). Disables sleep mode
// Improvement and testing needed for timings
bool lowPower = true;
// millis()-based, not wall-clock: this only measures in-session warmup elapsed time,
// and getTime() can jump discontinuously when RTC quality improves mid-session.
uint32_t co2MeasureStarted = 0;
public:
File diff suppressed because it is too large Load Diff
+7 -190
View File
@@ -1,200 +1,17 @@
#pragma once
#include "configuration.h"
#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
#include "../detect/ReClockI2C.h"
#include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "TelemetrySensor.h"
#include "Wire.h"
#include "gps/RTC.h"
#include "SENXXSensor.h"
// Warm up times for SEN5X from the datasheet
#ifndef SEN5X_WARMUP_MS_1
#define SEN5X_WARMUP_MS_1 15000
#endif
#ifndef SEN5X_WARMUP_MS_2
#define SEN5X_WARMUP_MS_2 30000
#endif
#ifndef SEN5X_POLL_INTERVAL
#define SEN5X_POLL_INTERVAL 1000
#endif
#ifndef SEN5X_I2C_CLOCK_SPEED
#define SEN5X_I2C_CLOCK_SPEED 100000
#endif
/*
Time after which the sensor can go to sleep, as the warmup period has passed
and the VOCs sensor will is allowed to stop (although needs to recover the state
each time)
*/
#ifndef SEN5X_VOC_STATE_WARMUP_S
/* Note for Testing 5' is enough
Sensirion recommends 1h
This can be bypassed completely if switching to low-power RHT/Gas mode and setting
SEN5X_VOC_STATE_WARMUP_S 0
*/
#define SEN5X_VOC_STATE_WARMUP_S 3600
#endif
#define ONE_WEEK_IN_SECONDS 604800
struct _SEN5XMeasurements {
uint16_t pM1p0;
uint16_t pM2p5;
uint16_t pM4p0;
uint16_t pM10p0;
uint32_t pN0p5;
uint32_t pN1p0;
uint32_t pN2p5;
uint32_t pN4p0;
uint32_t pN10p0;
float tSize;
float humidity;
float temperature;
float vocIndex;
float noxIndex;
};
class SEN5XSensor : public TelemetrySensor
// Thin identity wrapper around SENXXSensor for the SEN5X family (SEN50/54/55,
// I2C address SEN5X_ADDR / 0x69). All protocol/state-machine logic lives in
// SENXXSensor; the exact model is auto-detected in probe()/initDevice().
class SEN5XSensor : public SENXXSensor
{
private:
#ifdef SEN5X_I2C_CLOCK_SPEED
ReClockI2C reClockI2C;
#endif
bool getVersion();
float firmwareVer = -1;
float hardwareVer = -1;
float protocolVer = -1;
bool findModel();
// Commands
#define SEN5X_RESET 0xD304
#define SEN5X_GET_PRODUCT_NAME 0xD014
#define SEN5X_GET_FIRMWARE_VERSION 0xD100
#define SEN5X_START_MEASUREMENT 0x0021
#define SEN5X_START_MEASUREMENT_RHT_GAS 0x0037
#define SEN5X_STOP_MEASUREMENT 0x0104
#define SEN5X_READ_DATA_READY 0x0202
#define SEN5X_START_FAN_CLEANING 0x5607
#define SEN5X_RW_VOCS_STATE 0x6181
#define SEN5X_READ_VALUES 0x03C4
#define SEN5X_READ_RAW_VALUES 0x03D2
#define SEN5X_READ_PM_VALUES 0x0413
// Values the sensor reports when a reading is unavailable
#define SEN5X_UINT_INVALID 0xFFFF
#define SEN5X_INT_INVALID 0x7FFF
// Reply payload sizes in data bytes; the raw I2C transfer adds one CRC byte
// per 2-byte group, so requests are <size> + <size> / 2 raw bytes
#define SEN5X_VERSION_BUFFER_SIZE 8
#define SEN5X_PRODUCT_NAME_BUFFER_SIZE 32
#define SEN5X_DATA_READY_BUFFER_SIZE 2
#define SEN5X_READ_VALUES_BUFFER_SIZE 16
#define SEN5X_READ_PM_BUFFER_SIZE 20
#define SEN5X_VOC_VALID_TIME 600
#define SEN5X_VOC_VALID_DATE 1514764800
enum SEN5Xmodel { SEN5X_UNKNOWN = 0, SEN50 = 0b001, SEN54 = 0b010, SEN55 = 0b100 };
SEN5Xmodel model = SEN5X_UNKNOWN;
enum SEN5XState {
SEN5X_OFF,
SEN5X_IDLE,
SEN5X_RHTGAS_ONLY,
SEN5X_MEASUREMENT,
SEN5X_MEASUREMENT_2,
SEN5X_CLEANING,
SEN5X_NOT_DETECTED
};
SEN5XState state = SEN5X_OFF;
// Flag to work on one-shot (read and sleep), or continuous mode
bool oneShotMode = true;
void setMode(bool setOneShot);
bool vocStateValid();
/* Sensirion recommends taking a reading after 15 seconds,
if the Particle number reading is over 100#/cm3 the reading is OK,
but if it is lower wait until 30 seconds and take it again.
See: https://sensirion.com/resource/application_note/low_power_mode/sen5x
*/
#define SEN5X_PN4P0_CONC_THD 100
bool sendCommand(uint16_t command);
/**
* @brief Send a command word followed by a data payload; a CRC byte is
* computed and inserted on the wire after every 2-byte pair.
* @param command 16-bit command code, sent big-endian
* @param buffer payload data bytes, without CRCs
* @param byteNumber payload size in data bytes; must be even
* @return true when the full transfer is written and acknowledged
*/
bool sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNumber = 0);
/**
* @brief Read a reply, verifying and stripping the interleaved CRC bytes.
* @param buffer destination for the data bytes (byteNumber * 2 / 3 of them)
* @param byteNumber raw transfer size including CRCs; must be a multiple
* of 3 (2 data bytes + 1 CRC per group)
* @return the number of data bytes written to buffer, or 0 on any error
*/
uint8_t readBuffer(uint8_t *buffer, uint8_t byteNumber);
uint8_t sen5xCRC(const uint8_t *buffer);
bool startCleaning();
uint8_t getMeasurements();
// bool readRawValues();
bool readPNValues(bool cumulative);
bool readValues();
uint32_t pmMeasureStarted = 0;
uint32_t rhtGasMeasureStarted = 0;
uint32_t lastDataPoll = 0;
_SEN5XMeasurements sen5xmeasurement{};
bool idle(bool checkState = true);
protected:
// Store status of the sensor in this file
const char *sen5XStateFileName = "/prefs/sen5X.dat";
meshtastic_SEN5XState sen5xstate = meshtastic_SEN5XState_init_zero;
bool loadState();
bool saveState();
// Cleaning State
uint32_t lastCleaning = 0;
bool lastCleaningValid = false;
// VOC State
#define SEN5X_VOC_STATE_BUFFER_SIZE 8
uint8_t vocState[SEN5X_VOC_STATE_BUFFER_SIZE]{};
uint32_t vocTime = 0;
bool vocValid = false;
bool vocStateFromSensor();
bool vocStateToSensor();
bool vocStateStable();
bool vocStateRecent(uint32_t now);
public:
SEN5XSensor();
bool probe(TwoWire *bus, uint8_t address, ScanI2C::I2CPort port);
virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override;
virtual bool getMetrics(meshtastic_Telemetry *measurement) override;
virtual bool isActive() override;
virtual void sleep() override;
virtual uint32_t wakeUp() override;
virtual bool canSleep() override { return true; }
virtual int32_t wakeUpTimeMs() override;
virtual int32_t pendingForReadyMs() override;
AdminMessageHandleResult handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request,
meshtastic_AdminMessage *response) override;
SEN5XSensor() : SENXXSensor(meshtastic_TelemetrySensorType_SEN5X, "SEN5X") { senXXStateFileName = "/prefs/sen5X.dat"; }
};
#endif
@@ -0,0 +1,18 @@
#pragma once
#include "configuration.h"
#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
#include "SENXXSensor.h"
// Thin identity wrapper around SENXXSensor for the SEN6X family (SEN62, SEN63C,
// SEN65, SEN66, SEN68, SEN69C - I2C address SEN6X_ADDR / 0x6B). All
// protocol/state-machine logic lives in SENXXSensor; the exact model is
// auto-detected in probe()/initDevice().
class SEN6XSensor : public SENXXSensor
{
public:
SEN6XSensor() : SENXXSensor(meshtastic_TelemetrySensorType_SEN6X, "SEN6X") { senXXStateFileName = "/prefs/sen6X.dat"; }
};
#endif
File diff suppressed because it is too large Load Diff
+310
View File
@@ -0,0 +1,310 @@
#pragma once
#include "configuration.h"
#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
#include "../detect/ReClockI2C.h"
#include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "CO2Sensor.h"
#include "TelemetrySensor.h"
#include "Wire.h"
#include "gps/RTC.h"
/*
Shared driver for Sensirion's SEN5X and SEN6X particulate-matter sensor families
(SEN50/54/55 and SEN62/63C/65/66/68/69C). All of these sensors speak the same
16-bit-command + CRC8-framed word I2C protocol (reset, product name, start/stop
measurement, data-ready, fan cleaning, VOC algorithm state, ...). The families
differ only in:
- I2C address (SEN5X_ADDR 0x69 vs SEN6X_ADDR 0x6B)
- which physical quantities a given model exposes (PM is universal; RHT, VOC,
NOx, CO2 and HCHO are present on some models and not others)
- the opcode used to read measured values (SEN5X always uses one fixed-format
command; each SEN6X model has its own opcode returning only the words that
model supports)
This class implements the shared protocol, state machine and admin handling
once. SEN5XSensor / SEN6XSensor (see SEN5XSensor.h / SEN6XSensor.h) are thin
subclasses that only supply the sensorType/sensorName identity.
*/
#define SENXX_PM_WARMUP_MS_1 15000
#define SENXX_PM_WARMUP_MS_2 30000
#define SENXX_POLL_INTERVAL 1000
#define SENXX_I2C_CLOCK_SPEED 100000
// How long a fan-cleaning cycle takes once started; polled via pendingForReadyMs()
// rather than blocked on, see SENXX_CLEANING in SENXXState.
#define SENXX_CLEANING_DURATION_MS 10500
/*
Time after which the co2 sensor in some SEN6X variants give stable data
*/
#define SEN6X_CO2_WARMUP_MS 24000
#define SENXX_VOC_VALID_TIME 600
#define SENXX_VOC_VALID_DATE 1514764800
/*
Time after which the sensor can go to sleep, as the warmup period has passed
and the VOCs sensor will is allowed to stop (although needs to recover the state
each time)
Note: for Testing 5' is enough. Sensirion recommends 1h
This can be bypassed completely if switching to low-power RHT/Gas mode and setting
SENXX_VOC_STATE_WARMUP_S 0
*/
#define SENXX_VOC_STATE_WARMUP_S 3600
#define SENXX_VOC_STATE_BUFFER_SIZE 8
/* Sensirion recommends taking a reading after 15 seconds,
if the Particle number reading is over 100#/cm3 the reading is OK,
but if it is lower wait until 30 seconds and take it again.
See: https://sensirion.com/resource/application_note/low_power_mode/sen5x
*/
#define SENXX_PN4P0_CONC_THD 100
#ifndef ONE_WEEK_IN_SECONDS
#define ONE_WEEK_IN_SECONDS 604800
#endif
// Commands shared identically by every SEN5X/SEN6X model
#define SENXX_RESET 0xD304
#define SENXX_GET_PRODUCT_NAME 0xD014
#define SENXX_GET_FIRMWARE_VERSION 0xD100
#define SENXX_START_MEASUREMENT 0x0021
#define SENXX_STOP_MEASUREMENT 0x0104
#define SENXX_READ_DATA_READY 0x0202
#define SENXX_START_FAN_CLEANING 0x5607
#define SENXX_RW_VOCS_STATE 0x6181
// SEN5X-only: low-power "RHT/Gas only" measurement mode and fixed-format read commands
#define SEN5X_START_MEASUREMENT_RHT_GAS 0x0037
#define SEN5X_READ_VALUES 0x03C4
#define SEN5X_READ_PM_VALUES 0x0413
// SEN6X-only: shared number-concentration read command (per-model measured-values
// opcode lives in readMeasuredValuesCmd, set once the model is known)
#define SEN6X_READ_NUMBER_CONCENTRATION_VALUES 0x0316
// Values the sensor reports when a reading is unavailable (same sentinels across
// the whole SEN5X/SEN6X family per Sensirion's datasheets)
#define SENXX_UINT_INVALID 0xFFFF
#define SENXX_INT_INVALID 0x7FFF
// Reply payload sizes in data bytes; the raw I2C transfer adds one CRC byte per
// 2-byte group, so requests are <size> + <size> / 2 raw bytes
#define SENXX_VERSION_BUFFER_SIZE 8
#define SENXX_PRODUCT_NAME_BUFFER_SIZE 32
#define SENXX_DATA_READY_BUFFER_SIZE 2
#define SEN5X_READ_VALUES_BUFFER_SIZE 16
#define SEN5X_READ_PM_BUFFER_SIZE 20
// SEN6X-only commands (all models: SEN62/63C/65/66/68/69C)
#define SEN6X_GET_SET_TEMP_OFFSET 0x60B2
#define SEN6X_READ_DEVICE_STATUS 0xD206
// SEN6X-only, CO2-capable models only (SEN63C/66/69C)
#define SEN6X_PERFORM_FORCED_CO2_RECAL 0x6707
#define SEN6X_CO2_FACTORY_RESET 0x6754
#define SEN6X_GET_SET_CO2_ASC 0x6711
#define SEN6X_GET_SET_AMBIENT_PRESSURE 0x6720
#define SEN6X_GET_SET_ALTITUDE 0x6736
struct _SENXXMeasurements {
uint16_t pM1p0;
uint16_t pM2p5;
uint16_t pM4p0;
uint16_t pM10p0;
uint32_t pN0p5;
uint32_t pN1p0;
uint32_t pN2p5;
uint32_t pN4p0;
uint32_t pN10p0;
float tSize;
float humidity;
float temperature;
float vocIndex;
float noxIndex;
float co2;
float hcho;
};
class SENXXSensor : public TelemetrySensor, public CO2CalibrationSensor
{
protected:
// Only subclasses (SEN5XSensor / SEN6XSensor) construct this; they supply the
// proto sensorType/sensorName identity, everything else is auto-detected via
// findModel() at probe/init time.
SENXXSensor(meshtastic_TelemetrySensorType sensorType, const char *sensorName) : TelemetrySensor(sensorType, sensorName) {}
private:
#ifdef SENXX_I2C_CLOCK_SPEED
ReClockI2C reClockI2C;
#endif
bool getVersion();
float firmwareVer = -1;
float hardwareVer = -1;
float protocolVer = -1;
bool findModel();
enum SENXXmodel {
SENXX_UNKNOWN = 0,
// SEN5X family - I2C address SEN5X_ADDR (0x69)
SEN50,
SEN54,
SEN55,
// SEN6X family - I2C address SEN6X_ADDR (0x6B)
SEN62,
SEN63C,
SEN65,
SEN66,
SEN68,
SEN69C,
};
SENXXmodel model = SENXX_UNKNOWN;
// True for any SEN6X-family model (SEN62/63C/65/66/68/69C)
bool isSen6xFamily() { return model >= SEN62; }
// Per-model capabilities, derived once in updateCapabilities() right after
// findModel() succeeds. Every read/state routine below is written against
// these flags rather than against individual model checks, so adding a new
// family member only means extending findModel()/updateCapabilities().
bool hasRHT = false;
bool hasVOC = false;
bool hasNOx = false;
bool hasCO2 = false;
bool hasHCHO = false;
void updateCapabilities();
// SEN6X: opcode for "Read Measured Values" - differs per model, see updateCapabilities()
uint16_t readMeasuredValuesCmd = 0;
// Device Status Register bit positions (SEN6X only - see datasheet Figure 7)
static constexpr uint32_t SEN6X_STATUS_FAN_ERROR = 1u << 4;
static constexpr uint32_t SEN6X_STATUS_RHT_ERROR = 1u << 6;
static constexpr uint32_t SEN6X_STATUS_GAS_ERROR = 1u << 7;
static constexpr uint32_t SEN6X_STATUS_CO2_2_ERROR = 1u << 9; // SEN66 only
static constexpr uint32_t SEN6X_STATUS_HCHO_ERROR = 1u << 10;
static constexpr uint32_t SEN6X_STATUS_PM_ERROR = 1u << 11;
static constexpr uint32_t SEN6X_STATUS_CO2_1_ERROR = 1u << 12; // SEN63C/SEN69C only
static constexpr uint32_t SEN6X_STATUS_FAN_SPEED_WARNING = 1u << 21;
bool readDeviceStatus(uint32_t &statusFlags);
void logDeviceStatus(uint32_t statusFlags);
// Sets the SEN6X RHT temperature-offset compensation (slot 0, applied immediately)
// from the most recently measured temperature vs. a known-good reference. Unlike
// SCD4X/SCD30 there is no "get current offset" command to accumulate against, so
// this simply computes offset = lastMeasuredTemperature - tempReference.
bool setTemperatureOffset(float tempReference);
// CO2CalibrationSensor overrides - only meaningful when hasCO2 (SEN63C/66/69C);
// return false/no-op otherwise.
bool co2PerformFRC(uint32_t targetCO2ppm) override;
bool co2GetASC(bool &ascEnabled) override;
bool co2SetASC(bool ascEnabled) override;
bool co2SetAltitude(uint32_t altitude) override;
bool co2SetAmbientPressure(uint32_t ambientPressurePa) override;
bool co2FactoryReset() override;
enum SENXXState {
SENXX_OFF,
SENXX_IDLE,
SENXX_RHTGAS_ONLY, // SEN5X Only
SENXX_MEASUREMENT,
SENXX_MEASUREMENT_2,
SENXX_CLEANING,
SENXX_NOT_DETECTED
};
SENXXState state = SENXX_OFF;
// Flag to work on one-shot (read and sleep), or continuous mode
// Recommendation: if it has VOC / NOx, suggest NOT to use oneShot mode
bool oneShotMode = true;
void setMode(bool setOneShot);
bool vocStateValid();
// Tracks getRTCQuality() across calls so we can notice the moment a real clock
// becomes available (e.g. the phone/WiFi/GPS sets it well after boot), rather than
// only checking once in initDevice(). See checkRTCQualityImproved()/
// reconcileTimeDependentState() for how this is used.
RTCQuality lastRTCQuality = RTCQualityNone;
bool checkRTCQualityImproved();
void reconcileTimeDependentState(uint32_t now);
bool sendCommand(uint16_t command);
/**
* @brief Send a command word followed by a data payload; a CRC byte is
* computed and inserted on the wire after every 2-byte pair.
* @param command 16-bit command code, sent big-endian
* @param buffer payload data bytes, without CRCs
* @param byteNumber payload size in data bytes; must be even
* @return true when the full transfer is written and acknowledged
*/
bool sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNumber = 0);
/**
* @brief Read a reply, verifying and stripping the interleaved CRC bytes.
* @param buffer destination for the data bytes (byteNumber * 2 / 3 of them)
* @param byteNumber raw transfer size including CRCs; must be a multiple
* of 3 (2 data bytes + 1 CRC per group)
* @return the number of data bytes written to buffer, or 0 on any error
*/
uint8_t readBuffer(uint8_t *buffer, uint8_t byteNumber);
uint8_t senxxCRC(const uint8_t *buffer);
// Starts a fan-cleaning cycle and returns immediately (does not block for the
// ~10.5s the cycle takes); pendingForReadyMs() polls SENXX_CLEANING to completion
// and calls finishCleaning() once done.
bool startCleaning();
void finishCleaning();
uint8_t getMeasurements();
bool readPNValues(bool cumulative);
bool readValues();
// Monotonic (millis()) timers for warmup/poll intervals. Deliberately not
// wall-clock (getTime()) based: getTime() can jump discontinuously the moment the RTC
// quality improves mid-session (see checkRTCQualityImproved()), which would corrupt
// these short elapsed-time computations. millis() is immune to that and wraps only every ~49 days.
uint32_t pmMeasureStarted = 0;
uint32_t rhtGasMeasureStarted = 0;
uint32_t lastDataPoll = 0;
uint32_t cleaningStarted = 0;
_SENXXMeasurements senxxmeasurement{};
bool idle(bool checkState = true);
protected:
// Store status of the sensor in this file. SEN5X and SEN6X keep separate prefs
// files/proto messages so existing SEN5X saved state is unaffected.
const char *senXXStateFileName = nullptr;
meshtastic_SEN5XState sen5xstate = meshtastic_SEN5XState_init_zero;
meshtastic_SEN6XState sen6xstate = meshtastic_SEN6XState_init_zero;
bool loadState();
bool saveState();
// Cleaning State
uint32_t lastCleaning = 0;
bool lastCleaningValid = false;
// VOC State
uint8_t vocState[SENXX_VOC_STATE_BUFFER_SIZE]{};
uint32_t vocTime = 0;
bool vocValid = false;
bool vocStateFromSensor();
bool vocStateToSensor();
bool vocStateStable();
bool vocStateRecent(uint32_t now);
public:
bool probe(TwoWire *bus, uint8_t address, ScanI2C::I2CPort port);
virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override;
virtual bool getMetrics(meshtastic_Telemetry *measurement) override;
virtual bool isActive() override;
virtual void sleep() override;
virtual uint32_t wakeUp() override;
virtual bool canSleep() override { return true; }
virtual int32_t wakeUpTimeMs() override;
virtual int32_t pendingForReadyMs() override;
AdminMessageHandleResult handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request,
meshtastic_AdminMessage *response) override;
};
#endif
+4 -6
View File
@@ -46,8 +46,8 @@ bool SFA30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev)
status = 1;
state = State::ACTIVE;
measureStarted = getTime();
LOG_INFO("%s: Enabled", sensorName);
measureStarted = millis();
LOG_INFO("%s Enabled", sensorName);
initI2CSensor();
return true;
@@ -102,7 +102,7 @@ uint32_t SFA30Sensor::wakeUp()
#endif /* SFA30_I2C_CLOCK_SPEED */
state = State::ACTIVE;
measureStarted = getTime();
measureStarted = millis();
return SFA30_WARMUP_MS;
}
@@ -125,9 +125,7 @@ bool SFA30Sensor::isActive()
int32_t SFA30Sensor::pendingForReadyMs()
{
uint32_t now;
now = getTime();
uint32_t sinceHchoMeasureStarted = (now - measureStarted) * 1000;
uint32_t sinceHchoMeasureStarted = millis() - measureStarted;
LOG_DEBUG("%s: Since measure started: %ums", sensorName, sinceHchoMeasureStarted);
if (sinceHchoMeasureStarted < SFA30_WARMUP_MS) {
@@ -17,6 +17,8 @@ class SFA30Sensor : public TelemetrySensor
private:
enum class State { IDLE, ACTIVE };
State state = State::IDLE;
// millis()-based, not wall-clock: this only measures in-session warmup elapsed time,
// and getTime() can jump discontinuously when RTC quality improves mid-session.
uint32_t measureStarted = 0;
SensirionI2cSfa3x sfa30;