diff --git a/src/configuration.h b/src/configuration.h
index 795856955..1a6550366 100644
--- a/src/configuration.h
+++ b/src/configuration.h
@@ -300,6 +300,7 @@ along with this program. If not, see .
#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
diff --git a/src/detect/ScanI2C.cpp b/src/detect/ScanI2C.cpp
index a919bd105..eb0710101 100644
--- a/src/detect/ScanI2C.cpp
+++ b/src/detect/ScanI2C.cpp
@@ -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
diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h
index 46d5395a6..6a97370dc 100644
--- a/src/detect/ScanI2C.h
+++ b/src/detect/ScanI2C.h
@@ -96,6 +96,7 @@ class ScanI2C
CST3530,
BMI270,
SEN5X,
+ SEN6X,
SFA30,
CW2015,
SCD30,
diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp
index af484eb15..f0f4671f5 100644
--- a/src/detect/ScanI2CTwoWire.cpp
+++ b/src/detect/ScanI2CTwoWire.cpp
@@ -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);
}
diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp
index b67a18327..2eb596bd9 100644
--- a/src/modules/Telemetry/AirQualityTelemetry.cpp
+++ b/src/modules/Telemetry/AirQualityTelemetry.cpp
@@ -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()
#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()
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(i2cScanner, ScanI2C::DeviceType::PMSA003I);
addSensor(i2cScanner, ScanI2C::DeviceType::SEN5X);
+ addSensor(i2cScanner, ScanI2C::DeviceType::SEN6X);
#if __has_include()
addSensor(i2cScanner, ScanI2C::DeviceType::SCD4X);
#endif
diff --git a/src/modules/Telemetry/Sensor/CO2Sensor.h b/src/modules/Telemetry/Sensor/CO2Sensor.h
new file mode 100644
index 000000000..57e747a6f
--- /dev/null
+++ b/src/modules/Telemetry/Sensor/CO2Sensor.h
@@ -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;
+ }
+};
diff --git a/src/modules/Telemetry/Sensor/HM330XSensor.cpp b/src/modules/Telemetry/Sensor/HM330XSensor.cpp
index 20b2a5e66..91c0267e7 100644
--- a/src/modules/Telemetry/Sensor/HM330XSensor.cpp
+++ b/src/modules/Telemetry/Sensor/HM330XSensor.cpp
@@ -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) {
diff --git a/src/modules/Telemetry/Sensor/HM330XSensor.h b/src/modules/Telemetry/Sensor/HM330XSensor.h
index 76312b04c..f8edb0a47 100644
--- a/src/modules/Telemetry/Sensor/HM330XSensor.h
+++ b/src/modules/Telemetry/Sensor/HM330XSensor.h
@@ -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{};
diff --git a/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp b/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp
index c36605773..8b9151379 100644
--- a/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp
+++ b/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp
@@ -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
diff --git a/src/modules/Telemetry/Sensor/PMSA003ISensor.h b/src/modules/Telemetry/Sensor/PMSA003ISensor.h
index b65ef99a9..7243a0ddf 100644
--- a/src/modules/Telemetry/Sensor/PMSA003ISensor.h
+++ b/src/modules/Telemetry/Sensor/PMSA003ISensor.h
@@ -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]{};
diff --git a/src/modules/Telemetry/Sensor/SCD30Sensor.cpp b/src/modules/Telemetry/Sensor/SCD30Sensor.cpp
index c380f0f42..c2631f5b0 100644
--- a/src/modules/Telemetry/Sensor/SCD30Sensor.cpp
+++ b/src/modules/Telemetry/Sensor/SCD30Sensor.cpp
@@ -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);
}
}
diff --git a/src/modules/Telemetry/Sensor/SCD30Sensor.h b/src/modules/Telemetry/Sensor/SCD30Sensor.h
index 82c9a5532..51bc872ba 100644
--- a/src/modules/Telemetry/Sensor/SCD30Sensor.h
+++ b/src/modules/Telemetry/Sensor/SCD30Sensor.h
@@ -4,12 +4,13 @@
#include "../detect/ReClockI2C.h"
#include "../mesh/generated/meshtastic/telemetry.pb.h"
+#include "CO2Sensor.h"
#include "TelemetrySensor.h"
#include
#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(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(altitude));
+ }
+
// Parameters
uint16_t ascActive = 1;
uint16_t measurementInterval = 2;
diff --git a/src/modules/Telemetry/Sensor/SCD4XSensor.cpp b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp
index 7c6bc3ecf..261f8259b 100644
--- a/src/modules/Telemetry/Sensor/SCD4XSensor.cpp
+++ b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp
@@ -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;
diff --git a/src/modules/Telemetry/Sensor/SCD4XSensor.h b/src/modules/Telemetry/Sensor/SCD4XSensor.h
index f9161942e..af7703151 100644
--- a/src/modules/Telemetry/Sensor/SCD4XSensor.h
+++ b/src/modules/Telemetry/Sensor/SCD4XSensor.h
@@ -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
@@ -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(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(targetCO2ppm));
+ }
+ bool co2SetAltitude(uint32_t altitude) override
+ {
+ if (altitude > 3000)
+ return false;
+ return altitude <= UINT16_MAX && setAltitude(static_cast(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:
diff --git a/src/modules/Telemetry/Sensor/SEN5XSensor.cpp b/src/modules/Telemetry/Sensor/SEN5XSensor.cpp
deleted file mode 100644
index 37df1204b..000000000
--- a/src/modules/Telemetry/Sensor/SEN5XSensor.cpp
+++ /dev/null
@@ -1,1003 +0,0 @@
-#include "configuration.h"
-
-#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
-
-#include "../mesh/generated/meshtastic/telemetry.pb.h"
-#include "FSCommon.h"
-#include "SEN5XSensor.h"
-#include "SPILock.h"
-#include "SafeFile.h"
-#include "TelemetrySensor.h"
-#include // FLT_MAX
-#include
-#include
-
-SEN5XSensor::SEN5XSensor() : TelemetrySensor(meshtastic_TelemetrySensorType_SEN5X, "SEN5X") {}
-
-bool SEN5XSensor::getVersion()
-{
- if (!sendCommand(SEN5X_GET_FIRMWARE_VERSION)) {
- LOG_ERROR("%s: Error sending version command", sensorName);
- return false;
- }
- delay(20); // From Sensirion Datasheet
-
- // Version reply layout: fw major/minor, fw debug, hw major/minor,
- // protocol major/minor, padding
- uint8_t versionBuffer[SEN5X_VERSION_BUFFER_SIZE]{};
- size_t charNumber = readBuffer(&versionBuffer[0], SEN5X_VERSION_BUFFER_SIZE + (SEN5X_VERSION_BUFFER_SIZE / 2));
- if (charNumber < SEN5X_VERSION_BUFFER_SIZE) {
- LOG_ERROR("%s: Error getting device version value", sensorName);
- return false;
- }
-
- firmwareVer = versionBuffer[0] + (versionBuffer[1] / 10.0f);
- hardwareVer = versionBuffer[3] + (versionBuffer[4] / 10.0f);
- protocolVer = versionBuffer[5] + (versionBuffer[6] / 10.0f);
-
- LOG_INFO("%s: Firmware Version: %0.2f", sensorName, firmwareVer);
- LOG_INFO("%s: Hardware Version: %0.2f", sensorName, hardwareVer);
- LOG_INFO("%s: Protocol Version: %0.2f", sensorName, protocolVer);
-
- return true;
-}
-
-bool SEN5XSensor::findModel()
-{
- if (!sendCommand(SEN5X_GET_PRODUCT_NAME)) {
- LOG_ERROR("%s: Error asking for product name", sensorName);
- return false;
- }
- delay(50); // From Sensirion Datasheet
-
- uint8_t name[SEN5X_PRODUCT_NAME_BUFFER_SIZE]{};
- size_t charNumber = readBuffer(&name[0], SEN5X_PRODUCT_NAME_BUFFER_SIZE + (SEN5X_PRODUCT_NAME_BUFFER_SIZE / 2));
- bool foundModel = false;
-
- if (charNumber < SEN5X_PRODUCT_NAME_BUFFER_SIZE) {
- LOG_ERROR("%s: Error getting device name", sensorName);
- return foundModel;
- }
-
- // We only check the last character that defines the model SEN5X
- switch (name[4]) {
- case 48:
- model = SEN50;
- LOG_INFO("%s: found sensor model SEN50", sensorName);
- foundModel = true;
- break;
- case 52:
- model = SEN54;
- LOG_INFO("%s: found sensor model SEN54", sensorName);
- foundModel = true;
- break;
- case 53:
- model = SEN55;
- LOG_INFO("%s: found sensor model SEN55", sensorName);
- foundModel = true;
- break;
- }
-
- return foundModel;
-}
-
-bool SEN5XSensor::probe(TwoWire *bus, uint8_t address, ScanI2C::I2CPort port)
-{
- LOG_INFO("SEN5X: probing sensor");
-
- _bus = bus;
- _address = address;
-
-#ifdef SEN5X_I2C_CLOCK_SPEED
- _port = port;
- reClockI2C.setup(_bus, _port);
-#endif /* SEN5X_I2C_CLOCK_SPEED */
-
- if (!findModel()) {
- LOG_DEBUG("SEN5X: can't find SEN5X model");
- return false;
- }
-
- return true;
-}
-
-bool SEN5XSensor::sendCommand(uint16_t command)
-{
- uint8_t nothing;
- return sendCommand(command, ¬hing, 0);
-}
-
-bool SEN5XSensor::sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNumber)
-{
- // At least we need two bytes for the command
- uint8_t bufferSize = 2;
-
- // Add space for CRC bytes (one every two bytes)
- if (byteNumber > 0)
- bufferSize += byteNumber + (byteNumber / 2);
-
- uint8_t toSend[bufferSize];
- uint8_t i = 0;
- toSend[i++] = static_cast((command & 0xFF00) >> 8);
- toSend[i++] = static_cast((command & 0x00FF) >> 0);
-
- // Prepare buffer with CRC every third byte
- uint8_t bi = 0;
- if (byteNumber > 0) {
- while (bi < byteNumber) {
- toSend[i++] = buffer[bi++];
- toSend[i++] = buffer[bi++];
- uint8_t calcCRC = sen5xCRC(&buffer[bi - 2]);
- toSend[i++] = calcCRC;
- }
- }
-
-#ifdef SEN5X_I2C_CLOCK_SPEED
- reClockI2C.setClock(SEN5X_I2C_CLOCK_SPEED);
-#endif /* SEN5X_I2C_CLOCK_SPEED */
-
- // Transmit the data
- // LOG_DEBUG("Beginning connection to SEN5X: 0x%x. Size: %u", address, bufferSize);
- // Note: this delay is necessary to allow for long-buffers
- delay(20);
- _bus->beginTransmission(_address);
- size_t writtenBytes = _bus->write(toSend, bufferSize);
- uint8_t i2c_error = _bus->endTransmission();
-
-#ifdef SEN5X_I2C_CLOCK_SPEED
- reClockI2C.restoreClock();
-#endif /* SEN5X_I2C_CLOCK_SPEED */
-
- if (writtenBytes != bufferSize) {
- LOG_ERROR("%s: Error writing on I2C bus", sensorName);
- return false;
- }
-
- if (i2c_error != 0) {
- LOG_ERROR("%s: Error on I2C communication: %x", sensorName, i2c_error);
- return false;
- }
- return true;
-}
-
-uint8_t SEN5XSensor::readBuffer(uint8_t *buffer, uint8_t byteNumber)
-{
-#ifdef SEN5X_I2C_CLOCK_SPEED
- reClockI2C.setClock(SEN5X_I2C_CLOCK_SPEED);
-#endif /* SEN5X_I2C_CLOCK_SPEED */
-
- size_t readBytes = _bus->requestFrom(_address, byteNumber);
- if (readBytes != byteNumber) {
- LOG_ERROR("%s: Error reading I2C bus", sensorName);
-#ifdef SEN5X_I2C_CLOCK_SPEED
- reClockI2C.restoreClock();
-#endif /* SEN5X_I2C_CLOCK_SPEED */
- return 0;
- }
-
- uint8_t i = 0;
- uint8_t receivedBytes = 0;
- while (readBytes > 0) {
- buffer[i++] = _bus->read(); // Just as a reminder: i++ returns i and after that increments.
- buffer[i++] = _bus->read();
- uint8_t recvCRC = _bus->read();
- uint8_t calcCRC = sen5xCRC(&buffer[i - 2]);
- if (recvCRC != calcCRC) {
- LOG_ERROR("%s: Checksum error receiving msg", sensorName);
-#ifdef SEN5X_I2C_CLOCK_SPEED
- reClockI2C.restoreClock();
-#endif /* SEN5X_I2C_CLOCK_SPEED */
- return 0;
- }
- readBytes -= 3;
- receivedBytes += 2;
- }
-
-#ifdef SEN5X_I2C_CLOCK_SPEED
- reClockI2C.restoreClock();
-#endif /* SEN5X_I2C_CLOCK_SPEED */
-
- return receivedBytes;
-}
-
-uint8_t SEN5XSensor::sen5xCRC(const uint8_t *buffer)
-{
- // This code is based on Sensirion's own implementation
- // https://github.com/Sensirion/arduino-core/blob/41fd02cacf307ec4945955c58ae495e56809b96c/src/SensirionCrc.cpp
- uint8_t crc = 0xff;
-
- for (uint8_t i = 0; i < 2; i++) {
-
- crc ^= buffer[i];
-
- for (uint8_t bit = 8; bit > 0; bit--) {
- if (crc & 0x80)
- crc = (crc << 1) ^ 0x31;
- else
- crc = (crc << 1);
- }
- }
-
- return crc;
-}
-
-void SEN5XSensor::sleep()
-{
- idle(true);
-}
-
-bool SEN5XSensor::idle(bool checkState)
-{
- // From the datasheet:
- // By default, the VOC algorithm resets its state to initial
- // values each time a measurement is started,
- // even if the measurement was stopped only for a short
- // time. So, the VOC index output value needs a long time
- // until it is stable again. This can be avoided by
- // restoring the previously memorized algorithm state before
- // starting the measure mode
-
- if (checkState) {
- // If the stabilisation period is not passed for SEN54 or SEN55, don't go to idle
- if (model != SEN50) {
- // Get VOC state before going to idle mode
- vocValid = false;
- if (vocStateFromSensor()) {
- vocValid = vocStateValid();
- // Check if we have time, and store it
- uint32_t now; // If time is RTCQualityNone, it will return zero
- now = getValidTime(RTCQuality::RTCQualityDevice);
- // Check if state is valid (non-zero)
- if (now) {
- vocTime = now;
- }
- }
-
- if (!(vocStateStable() && vocValid)) {
- LOG_INFO("%s: Not stopping measurement, vocState not stable yet", sensorName);
- return true;
- }
- }
- // Save state and prefs (on all models)
- saveState();
- }
-
- if (!oneShotMode) {
- LOG_INFO("%s: Not stopping measurement, continuous mode", sensorName);
- return true;
- } else {
- LOG_INFO("%s: One shot mode enabled", sensorName);
- }
-
- // Switch to low-power based on the model
- if (model == SEN50) {
- if (!sendCommand(SEN5X_STOP_MEASUREMENT)) {
- LOG_ERROR("%s: Error stopping measurement", sensorName);
- return false;
- }
- state = SEN5X_IDLE;
- LOG_INFO("%s: Stop measurement mode", sensorName);
- } else {
- if (!sendCommand(SEN5X_START_MEASUREMENT_RHT_GAS)) {
- LOG_ERROR("%s: Error switching to RHT/Gas measurement", sensorName);
- return false;
- }
- state = SEN5X_RHTGAS_ONLY;
- LOG_INFO("%s: Switch to RHT/Gas only measurement mode", sensorName);
- }
-
- delay(200); // From Sensirion Datasheet
- pmMeasureStarted = 0;
- return true;
-}
-
-bool SEN5XSensor::vocStateRecent(uint32_t now)
-{
- if (now) {
- uint32_t passed = now - vocTime; // in seconds
-
- // Check if state is recent, less than 10 minutes (600 seconds)
- if (passed < SEN5X_VOC_VALID_TIME && (now > SEN5X_VOC_VALID_DATE)) {
- return true;
- }
- }
- return false;
-}
-
-bool SEN5XSensor::vocStateValid()
-{
- if (!vocState[0] && !vocState[1] && !vocState[2] && !vocState[3] && !vocState[4] && !vocState[5] && !vocState[6] &&
- !vocState[7]) {
- LOG_DEBUG("%s: VOC state is all 0, invalid", sensorName);
- return false;
- } else {
- LOG_DEBUG("%s: VOC state is valid", sensorName);
- return true;
- }
-}
-
-bool SEN5XSensor::vocStateToSensor()
-{
- if (model == SEN50) {
- return true;
- }
-
- if (!vocStateValid()) {
- LOG_INFO("%s: VOC state is invalid, not sending", sensorName);
- return true;
- }
-
- if (!sendCommand(SEN5X_STOP_MEASUREMENT)) {
- LOG_ERROR("%s: Error stopping measurement", sensorName);
- return false;
- }
- delay(200); // From Sensirion Datasheet
-
- LOG_DEBUG("%s: Sending VOC state to sensor", sensorName);
- LOG_DEBUG("[%u, %u, %u, %u, %u, %u, %u, %u]", vocState[0], vocState[1], vocState[2], vocState[3], vocState[4], vocState[5],
- vocState[6], vocState[7]);
-
- // Note: send command already takes into account the CRC
- // buffer size increment needed
- if (!sendCommand(SEN5X_RW_VOCS_STATE, vocState, SEN5X_VOC_STATE_BUFFER_SIZE)) {
- LOG_ERROR("%s: Error sending VOC's state command", sensorName);
- return false;
- }
-
- return true;
-}
-
-bool SEN5XSensor::vocStateFromSensor()
-{
- if (model == SEN50) {
- return true;
- }
-
- LOG_INFO("%s: Getting VOC state from sensor", sensorName);
- // Ask VOCs state from the sensor
- if (!sendCommand(SEN5X_RW_VOCS_STATE)) {
- LOG_ERROR("%s: Error sending VOC's state command", sensorName);
- return false;
- }
-
- delay(20); // From Sensirion Datasheet
-
- // Retrieve the data into a staging buffer so a partial read (e.g. a CRC
- // failure halfway through) cannot corrupt the current vocState.
- // The requested size accounts for the CRC bytes
- uint8_t stateBuffer[SEN5X_VOC_STATE_BUFFER_SIZE]{};
- size_t receivedNumber = readBuffer(&stateBuffer[0], SEN5X_VOC_STATE_BUFFER_SIZE + (SEN5X_VOC_STATE_BUFFER_SIZE / 2));
- delay(20); // From Sensirion Datasheet
-
- if (receivedNumber < SEN5X_VOC_STATE_BUFFER_SIZE) {
- LOG_DEBUG("%s: Error getting VOC's state", sensorName);
- return false;
- }
- memcpy(vocState, stateBuffer, SEN5X_VOC_STATE_BUFFER_SIZE);
-
- // Print the state (if debug is on)
- LOG_DEBUG("%s: VOC state from sensor: [%u, %u, %u, %u, %u, %u, %u, %u]", sensorName, vocState[0], vocState[1], vocState[2],
- vocState[3], vocState[4], vocState[5], vocState[6], vocState[7]);
-
- return true;
-}
-
-bool SEN5XSensor::loadState()
-{
-#ifdef FSCom
- spiLock->lock();
- auto file = FSCom.open(sen5XStateFileName, FILE_O_READ);
- bool okay = false;
- if (file) {
- LOG_INFO("%s: state read from %s", sensorName, sen5XStateFileName);
- pb_istream_t stream = {&readcb, &file, meshtastic_SEN5XState_size};
-
- if (!pb_decode(&stream, &meshtastic_SEN5XState_msg, &sen5xstate)) {
- LOG_ERROR("%s: can't decode protobuf %s", sensorName, PB_GET_ERROR(&stream));
- } else {
- lastCleaning = sen5xstate.last_cleaning_time;
- lastCleaningValid = sen5xstate.last_cleaning_valid;
- oneShotMode = sen5xstate.one_shot_mode;
-
- if (model != SEN50) {
- vocTime = sen5xstate.voc_state_time;
- vocValid = sen5xstate.voc_state_valid;
- // Unpack state
- vocState[7] = (uint8_t)(sen5xstate.voc_state_array >> 56);
- vocState[6] = (uint8_t)(sen5xstate.voc_state_array >> 48);
- vocState[5] = (uint8_t)(sen5xstate.voc_state_array >> 40);
- vocState[4] = (uint8_t)(sen5xstate.voc_state_array >> 32);
- vocState[3] = (uint8_t)(sen5xstate.voc_state_array >> 24);
- vocState[2] = (uint8_t)(sen5xstate.voc_state_array >> 16);
- vocState[1] = (uint8_t)(sen5xstate.voc_state_array >> 8);
- vocState[0] = (uint8_t)sen5xstate.voc_state_array;
- }
-
- // LOG_DEBUG("Loaded lastCleaning %u", lastCleaning);
- // LOG_DEBUG("Loaded lastCleaningValid %u", lastCleaningValid);
- // LOG_DEBUG("Loaded oneShotMode %s", oneShotMode ? "true" : "false");
- // LOG_DEBUG("Loaded vocTime %u", vocTime);
- // LOG_DEBUG("Loaded [%u, %u, %u, %u, %u, %u, %u, %u]",
- // vocState[7], vocState[6], vocState[5], vocState[4], vocState[3], vocState[2], vocState[1], vocState[0]);
- // LOG_DEBUG("Loaded %svalid VOC state", vocValid ? "" : "in");
-
- okay = true;
- }
- file.close();
- } else {
- LOG_INFO("%s: No state found (File: %s)", sensorName, sen5XStateFileName);
- }
- spiLock->unlock();
- return okay;
-#else
- LOG_ERROR("%s: Filesystem not implemented", sensorName);
- return false;
-#endif
-}
-
-bool SEN5XSensor::saveState()
-{
-#ifdef FSCom
- auto file = SafeFile(sen5XStateFileName);
-
- sen5xstate.last_cleaning_time = lastCleaning;
- sen5xstate.last_cleaning_valid = lastCleaningValid;
- sen5xstate.one_shot_mode = oneShotMode;
-
- if (model != SEN50) {
- sen5xstate.has_voc_state_time = true;
- sen5xstate.has_voc_state_valid = true;
- sen5xstate.has_voc_state_array = true;
-
- sen5xstate.voc_state_time = vocTime;
- sen5xstate.voc_state_valid = vocValid;
- // Unpack state (8 bytes)
- sen5xstate.voc_state_array = (((uint64_t)vocState[7]) << 56) | ((uint64_t)vocState[6] << 48) |
- ((uint64_t)vocState[5] << 40) | ((uint64_t)vocState[4] << 32) |
- ((uint64_t)vocState[3] << 24) | ((uint64_t)vocState[2] << 16) |
- ((uint64_t)vocState[1] << 8) | ((uint64_t)vocState[0]);
- }
-
- bool okay = false;
-
- LOG_INFO("%s: state write to %s", sensorName, sen5XStateFileName);
- pb_ostream_t stream = {&writecb, static_cast(&file), meshtastic_SEN5XState_size};
-
- if (!pb_encode(&stream, &meshtastic_SEN5XState_msg, &sen5xstate)) {
- LOG_ERROR("%s: can't encode protobuf %s", sensorName, PB_GET_ERROR(&stream));
- } else {
- okay = true;
- }
-
- okay &= file.close();
-
- if (okay)
- LOG_INFO("%s: state write to %s OK", sensorName, sen5XStateFileName);
-
- return okay;
-#else
- LOG_ERROR("%s: Filesystem not implemented", sensorName);
- return false;
-#endif
-}
-
-bool SEN5XSensor::isActive()
-{
- return state == SEN5X_MEASUREMENT || state == SEN5X_MEASUREMENT_2;
-}
-
-uint32_t SEN5XSensor::wakeUp()
-{
-
- LOG_TRACE("%s Waking", sensorName);
-
- if (!sendCommand(SEN5X_START_MEASUREMENT)) {
- LOG_ERROR("%s: Error starting measurement", sensorName);
- // TODO - what should this return?? Something actually on the default interval?
- return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS;
- }
- delay(50); // From Sensirion Datasheet
-
- // TODO - This is currently "problematic"
- // If time is updated in between reads, there is no way to
- // keep track of how long it has passed
- pmMeasureStarted = getTime();
- state = SEN5X_MEASUREMENT;
- LOG_INFO("%s: Started measurement mode", sensorName);
- return SEN5X_WARMUP_MS_1;
-}
-
-bool SEN5XSensor::vocStateStable()
-{
- uint32_t now;
- now = getTime();
- uint32_t sinceFirstMeasureStarted = (now - rhtGasMeasureStarted);
- LOG_TRACE("%s: sinceFirstMeasureStarted: %us", sensorName, sinceFirstMeasureStarted);
- return sinceFirstMeasureStarted > SEN5X_VOC_STATE_WARMUP_S;
-}
-
-bool SEN5XSensor::startCleaning()
-{
- // Note: we only should enter here if we have a valid RTC with at least
- // RTCQuality::RTCQualityDevice
- state = SEN5X_CLEANING;
-
- // Note that cleaning command can only be run when the sensor is in measurement mode
- if (!sendCommand(SEN5X_START_MEASUREMENT)) {
- LOG_ERROR("%s: Error starting measurement mode", sensorName);
- return false;
- }
- delay(50); // From Sensirion Datasheet
-
- if (!sendCommand(SEN5X_START_FAN_CLEANING)) {
- LOG_ERROR("%s: Error starting fan cleaning", sensorName);
- return false;
- }
- delay(20); // From Sensirion Datasheet
-
- // This message will be always printed so the user knows the device it's not hung
- LOG_INFO("%s: Started fan cleaning (10 sec)", sensorName);
-
- uint32_t started = millis();
- while (millis() - started < 10500) {
- delay(500);
- }
- LOG_INFO("%s: Cleaning done", sensorName);
-
- // Save timestamp in flash so we know when a week has passed
- uint32_t now;
- now = getValidTime(RTCQuality::RTCQualityDevice);
- // If time is not RTCQualityNone, it will return non-zero
- lastCleaning = now;
- lastCleaningValid = true;
- saveState();
-
- idle();
- return true;
-}
-
-bool SEN5XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev)
-{
- state = SEN5X_NOT_DETECTED;
- LOG_INFO("%s: Init sensor", sensorName);
-
- _bus = bus;
- _address = dev->address.address;
-#ifdef SEN5X_I2C_CLOCK_SPEED
- _port = dev->address.port;
- reClockI2C.setup(_bus, _port);
-#endif /* SEN5X_I2C_CLOCK_SPEED */
-
- delay(50); // without this there is an error on the deviceReset function
-
- if (!sendCommand(SEN5X_RESET)) {
- LOG_ERROR("%s: error resetting device", sensorName);
- return false;
- }
- delay(200); // From Sensirion Datasheet
-
- if (!findModel()) {
- LOG_ERROR("%s: error finding sensor model", sensorName);
- return false;
- }
-
- // Check the firmware version
- if (!getVersion())
- return false;
- if (firmwareVer < 2) {
- LOG_ERROR("%s: firmware too old, unsupported", sensorName);
- return false;
- }
- delay(200); // From Sensirion Datasheet
-
- // Detection succeeded
- state = SEN5X_IDLE;
- status = 1;
-
- // Load state
- loadState();
-
- // Check if it is time to do a cleaning
- uint32_t now;
- int32_t passed = 0;
- now = getValidTime(RTCQuality::RTCQualityDevice);
-
- // If time is not RTCQualityNone, it will return non-zero
- if (now) {
- if (lastCleaningValid) {
-
- passed = now - lastCleaning; // in seconds
-
- if (passed > ONE_WEEK_IN_SECONDS && (now > SEN5X_VOC_VALID_DATE)) {
- // If current date greater than 01/01/2018 (validity check)
- LOG_INFO("%s: Over a week (%us) since last cleaning (%us), trigger cleaning", sensorName, passed, lastCleaning);
- startCleaning();
- } else {
- LOG_INFO("%s: Cleaning not needed (%ds passed), last cleaning: %us", sensorName, passed, lastCleaning);
- }
- } else {
- // We assume the device has just been updated or it is new,
- // so no need to trigger a cleaning.
- // Just save the timestamp to do a cleaning one week from now.
- // Otherwise, we will never trigger cleaning in some cases
- lastCleaning = now;
- lastCleaningValid = true;
- LOG_INFO("%s: No valid last cleaning date, saving now: %us", sensorName, lastCleaning);
- saveState();
- }
-
- if (model != SEN50) {
- if (!vocValid) {
- LOG_INFO("%s: No valid VOC's state found", sensorName);
- } else {
- // Check if state is recent
- if (vocStateRecent(now)) {
- // If current date greater than 01/01/2018 (validity check)
- // Send it to the sensor
- LOG_INFO("%s: VOC state is valid and recent", sensorName);
- vocStateToSensor();
- } else {
- LOG_INFO("%s: VOC state too old or date invalid", sensorName);
- LOG_DEBUG("%s: vocTime %u, Passed %u, and now %u", sensorName, vocTime, passed, now);
- }
- }
- }
- } else {
- // TODO - Should this actually ignore? We could end up never cleaning...
- LOG_INFO("%s: Not enough RTCQuality, ignoring saved cleaning and VOC state", sensorName);
- }
-
- idle(false);
- rhtGasMeasureStarted = now;
-
- initI2CSensor();
- return true;
-}
-
-bool SEN5XSensor::readValues()
-{
- if (!sendCommand(SEN5X_READ_VALUES)) {
- LOG_ERROR("%s: Error sending read command", sensorName);
- return false;
- }
- LOG_TRACE("%s: Reading PM Values", sensorName);
- delay(20); // From Sensirion Datasheet
-
- uint8_t dataBuffer[SEN5X_READ_VALUES_BUFFER_SIZE]{};
- size_t receivedNumber = readBuffer(&dataBuffer[0], SEN5X_READ_VALUES_BUFFER_SIZE + (SEN5X_READ_VALUES_BUFFER_SIZE / 2));
- if (receivedNumber < SEN5X_READ_VALUES_BUFFER_SIZE) {
- LOG_ERROR("%s: Error getting values", sensorName);
- return false;
- }
-
- // Get the integers
- uint16_t uint_pM1p0 = static_cast((dataBuffer[0] << 8) | dataBuffer[1]);
- uint16_t uint_pM2p5 = static_cast((dataBuffer[2] << 8) | dataBuffer[3]);
- uint16_t uint_pM4p0 = static_cast((dataBuffer[4] << 8) | dataBuffer[5]);
- uint16_t uint_pM10p0 = static_cast((dataBuffer[6] << 8) | dataBuffer[7]);
-
- int16_t int_humidity = static_cast((dataBuffer[8] << 8) | dataBuffer[9]);
- int16_t int_temperature = static_cast((dataBuffer[10] << 8) | dataBuffer[11]);
- int16_t int_vocIndex = static_cast((dataBuffer[12] << 8) | dataBuffer[13]);
- int16_t int_noxIndex = static_cast((dataBuffer[14] << 8) | dataBuffer[15]);
-
- // Convert values based on Sensirion Arduino lib.
- // Map values the sensor reports as unavailable (SEN5X_UINT_INVALID /
- // SEN5X_INT_INVALID) to the sentinels getMetrics() checks for
- sen5xmeasurement.pM1p0 = (uint_pM1p0 != SEN5X_UINT_INVALID) ? (uint_pM1p0 / 10) : UINT16_MAX;
- sen5xmeasurement.pM2p5 = (uint_pM2p5 != SEN5X_UINT_INVALID) ? (uint_pM2p5 / 10) : UINT16_MAX;
- sen5xmeasurement.pM4p0 = (uint_pM4p0 != SEN5X_UINT_INVALID) ? (uint_pM4p0 / 10) : UINT16_MAX;
- sen5xmeasurement.pM10p0 = (uint_pM10p0 != SEN5X_UINT_INVALID) ? (uint_pM10p0 / 10) : UINT16_MAX;
- sen5xmeasurement.humidity = (int_humidity != SEN5X_INT_INVALID) ? (int_humidity / 100.0f) : FLT_MAX;
- sen5xmeasurement.temperature = (int_temperature != SEN5X_INT_INVALID) ? (int_temperature / 200.0f) : FLT_MAX;
- sen5xmeasurement.vocIndex = (int_vocIndex != SEN5X_INT_INVALID) ? (int_vocIndex / 10.0f) : FLT_MAX;
- sen5xmeasurement.noxIndex = (int_noxIndex != SEN5X_INT_INVALID) ? (int_noxIndex / 10.0f) : FLT_MAX;
-
- LOG_TRACE("%s: Got readings: pM1p0=%u, pM2p5=%u, pM4p0=%u, pM10p0=%u", sensorName, sen5xmeasurement.pM1p0,
- sen5xmeasurement.pM2p5, sen5xmeasurement.pM4p0, sen5xmeasurement.pM10p0);
-
- if (model != SEN50) {
- LOG_TRACE("%s: Got readings: humidity=%.2f, temperature=%.2f, vocIndex=%.2f", sensorName, sen5xmeasurement.humidity,
- sen5xmeasurement.temperature, sen5xmeasurement.vocIndex);
- }
-
- if (model == SEN55) {
- LOG_TRACE("%s: Got readings: noxIndex=%.2f", sensorName, sen5xmeasurement.noxIndex);
- }
-
- return true;
-}
-
-bool SEN5XSensor::readPNValues(bool cumulative)
-{
- if (!sendCommand(SEN5X_READ_PM_VALUES)) {
- LOG_ERROR("%s: Error sending read command", sensorName);
- return false;
- }
-
- LOG_TRACE("%s: Reading PN Values", sensorName);
- delay(20); // From Sensirion Datasheet
-
- uint8_t dataBuffer[SEN5X_READ_PM_BUFFER_SIZE]{};
- size_t receivedNumber = readBuffer(&dataBuffer[0], SEN5X_READ_PM_BUFFER_SIZE + (SEN5X_READ_PM_BUFFER_SIZE / 2));
- if (receivedNumber < SEN5X_READ_PM_BUFFER_SIZE) {
- LOG_ERROR("%s: Error getting PN values", sensorName);
- return false;
- }
-
- // Get the integers
- // uint16_t uint_pM1p0 = static_cast((dataBuffer[0] << 8) | dataBuffer[1]);
- // uint16_t uint_pM2p5 = static_cast((dataBuffer[2] << 8) | dataBuffer[3]);
- // uint16_t uint_pM4p0 = static_cast((dataBuffer[4] << 8) | dataBuffer[5]);
- // uint16_t uint_pM10p0 = static_cast((dataBuffer[6] << 8) | dataBuffer[7]);
- uint16_t uint_pN0p5 = static_cast((dataBuffer[8] << 8) | dataBuffer[9]);
- uint16_t uint_pN1p0 = static_cast((dataBuffer[10] << 8) | dataBuffer[11]);
- uint16_t uint_pN2p5 = static_cast((dataBuffer[12] << 8) | dataBuffer[13]);
- uint16_t uint_pN4p0 = static_cast((dataBuffer[14] << 8) | dataBuffer[15]);
- uint16_t uint_pN10p0 = static_cast((dataBuffer[16] << 8) | dataBuffer[17]);
- uint16_t uint_tSize = static_cast((dataBuffer[18] << 8) | dataBuffer[19]);
-
- // Convert values based on Sensirion Arduino lib.
- // Raw PN values are #/cm3 with 0.1 resolution; multiplying by 10
- // converts to #/0.1l without the truncation of dividing first.
- // Map values the sensor reports as unavailable (SEN5X_UINT_INVALID) to the
- // sentinels getMetrics() checks for
- sen5xmeasurement.pN0p5 = (uint_pN0p5 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN0p5 * 10) : UINT32_MAX;
- sen5xmeasurement.pN1p0 = (uint_pN1p0 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN1p0 * 10) : UINT32_MAX;
- sen5xmeasurement.pN2p5 = (uint_pN2p5 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN2p5 * 10) : UINT32_MAX;
- sen5xmeasurement.pN4p0 = (uint_pN4p0 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN4p0 * 10) : UINT32_MAX;
- sen5xmeasurement.pN10p0 = (uint_pN10p0 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN10p0 * 10) : UINT32_MAX;
- sen5xmeasurement.tSize = (uint_tSize != SEN5X_UINT_INVALID) ? (uint_tSize / 1000.0f) : FLT_MAX;
-
- // Remove accumuluative values:
- // https://github.com/fablabbcn/smartcitizen-kit-2x/issues/85
- if (!cumulative) {
- if (sen5xmeasurement.pN10p0 != UINT32_MAX && sen5xmeasurement.pN4p0 != UINT32_MAX)
- sen5xmeasurement.pN10p0 -= sen5xmeasurement.pN4p0;
- if (sen5xmeasurement.pN4p0 != UINT32_MAX && sen5xmeasurement.pN2p5 != UINT32_MAX)
- sen5xmeasurement.pN4p0 -= sen5xmeasurement.pN2p5;
- if (sen5xmeasurement.pN2p5 != UINT32_MAX && sen5xmeasurement.pN1p0 != UINT32_MAX)
- sen5xmeasurement.pN2p5 -= sen5xmeasurement.pN1p0;
- if (sen5xmeasurement.pN1p0 != UINT32_MAX && sen5xmeasurement.pN0p5 != UINT32_MAX)
- sen5xmeasurement.pN1p0 -= sen5xmeasurement.pN0p5;
- }
-
- LOG_TRACE("%s: Got readings: pN0p5=%u, pN1p0=%u, pN2p5=%u, pN4p0=%u, pN10p0=%u, tSize=%.2f", sensorName,
- sen5xmeasurement.pN0p5, sen5xmeasurement.pN1p0, sen5xmeasurement.pN2p5, sen5xmeasurement.pN4p0,
- sen5xmeasurement.pN10p0, sen5xmeasurement.tSize);
-
- return true;
-}
-
-uint8_t SEN5XSensor::getMeasurements()
-{
- uint32_t now;
- now = getTime();
-
- // Try to get new data
- if (!sendCommand(SEN5X_READ_DATA_READY)) {
- LOG_ERROR("%s: Error sending command data ready flag", sensorName);
- return 2;
- }
- delay(20); // From Sensirion Datasheet
-
- uint8_t dataReadyBuffer[SEN5X_DATA_READY_BUFFER_SIZE]{};
- size_t charNumber = readBuffer(&dataReadyBuffer[0], SEN5X_DATA_READY_BUFFER_SIZE + (SEN5X_DATA_READY_BUFFER_SIZE / 2));
- if (charNumber < SEN5X_DATA_READY_BUFFER_SIZE) {
- LOG_ERROR("%s: Error getting data ready flag value", sensorName);
- return 2;
- }
-
- bool dataReady = dataReadyBuffer[1];
- uint32_t sinceLastDataPollMs = (now - lastDataPoll) * 1000;
- // Check if data is ready, and if since last time we requested is less than SEN5X_POLL_INTERVAL
- if (!dataReady && (sinceLastDataPollMs > SEN5X_POLL_INTERVAL)) {
- LOG_INFO("%s: Data is not ready", sensorName);
- return 1;
- }
-
- if (!readValues()) {
- LOG_ERROR("%s: Error getting readings", sensorName);
- return 2;
- }
-
- if (!readPNValues(false)) {
- LOG_ERROR("%s: Error getting PN readings", sensorName);
- return 2;
- }
-
- lastDataPoll = now;
-
- return 0;
-}
-
-int32_t SEN5XSensor::wakeUpTimeMs()
-{
- return SEN5X_WARMUP_MS_2;
-}
-
-int32_t SEN5XSensor::pendingForReadyMs()
-{
- uint32_t now;
- now = getTime();
- uint32_t sincePmMeasureStarted = (now - pmMeasureStarted) * 1000;
- LOG_TRACE("%s: Since measure started: %ums", sensorName, sincePmMeasureStarted);
-
- switch (state) {
- case SEN5X_MEASUREMENT: {
-
- if (sincePmMeasureStarted < SEN5X_WARMUP_MS_1) {
- LOG_INFO("%s: not enough time since measurement start", sensorName);
- return SEN5X_WARMUP_MS_1 - sincePmMeasureStarted;
- }
-
- if (!pmMeasureStarted) {
- pmMeasureStarted = now;
- }
-
- // Get PN values to check if we are above or below threshold
- readPNValues(true);
- lastDataPoll = now;
-
- // If the reading is low (the tyhreshold is in #/cm3) and second warmUp hasn't passed we return to come back later
- if ((sen5xmeasurement.pN4p0 / 100) < SEN5X_PN4P0_CONC_THD && sincePmMeasureStarted < SEN5X_WARMUP_MS_2) {
- LOG_INFO("%s: Concentration low, will ask again in second warm up period", sensorName);
- state = SEN5X_MEASUREMENT_2;
- // Report how many seconds are pending to cover the first warm up period
- return SEN5X_WARMUP_MS_2 - sincePmMeasureStarted;
- }
- return 0;
- }
- case SEN5X_MEASUREMENT_2: {
- if (sincePmMeasureStarted < SEN5X_WARMUP_MS_2) {
- // Report how many seconds are pending to cover the first warm up period
- return SEN5X_WARMUP_MS_2 - sincePmMeasureStarted;
- }
- return 0;
- }
- default: {
- return -1;
- }
- }
-}
-
-bool SEN5XSensor::getMetrics(meshtastic_Telemetry *measurement)
-{
- LOG_INFO("%s: Get metrics", sensorName);
- if (!isActive()) {
- LOG_INFO("%s: Not in measurement mode", sensorName);
- return false;
- }
-
- uint8_t response;
- response = getMeasurements();
-
- if (response == 0) {
- if (sen5xmeasurement.pM1p0 != UINT16_MAX) {
- measurement->variant.air_quality_metrics.has_pm10_standard = true;
- measurement->variant.air_quality_metrics.pm10_standard = sen5xmeasurement.pM1p0;
- }
- if (sen5xmeasurement.pM2p5 != UINT16_MAX) {
- measurement->variant.air_quality_metrics.has_pm25_standard = true;
- measurement->variant.air_quality_metrics.pm25_standard = sen5xmeasurement.pM2p5;
- }
- if (sen5xmeasurement.pM4p0 != UINT16_MAX) {
- measurement->variant.air_quality_metrics.has_pm40_standard = true;
- measurement->variant.air_quality_metrics.pm40_standard = sen5xmeasurement.pM4p0;
- }
- if (sen5xmeasurement.pM10p0 != UINT16_MAX) {
- measurement->variant.air_quality_metrics.has_pm100_standard = true;
- measurement->variant.air_quality_metrics.pm100_standard = sen5xmeasurement.pM10p0;
- }
- if (sen5xmeasurement.pN0p5 != UINT32_MAX) {
- measurement->variant.air_quality_metrics.has_particles_05um = true;
- measurement->variant.air_quality_metrics.particles_05um = sen5xmeasurement.pN0p5;
- }
- if (sen5xmeasurement.pN1p0 != UINT32_MAX) {
- measurement->variant.air_quality_metrics.has_particles_10um = true;
- measurement->variant.air_quality_metrics.particles_10um = sen5xmeasurement.pN1p0;
- }
- if (sen5xmeasurement.pN2p5 != UINT32_MAX) {
- measurement->variant.air_quality_metrics.has_particles_25um = true;
- measurement->variant.air_quality_metrics.particles_25um = sen5xmeasurement.pN2p5;
- }
- if (sen5xmeasurement.pN4p0 != UINT32_MAX) {
- measurement->variant.air_quality_metrics.has_particles_40um = true;
- measurement->variant.air_quality_metrics.particles_40um = sen5xmeasurement.pN4p0;
- }
- if (sen5xmeasurement.pN10p0 != UINT32_MAX) {
- measurement->variant.air_quality_metrics.has_particles_100um = true;
- measurement->variant.air_quality_metrics.particles_100um = sen5xmeasurement.pN10p0;
- }
- if (sen5xmeasurement.tSize != FLT_MAX) {
- measurement->variant.air_quality_metrics.has_particles_tps = true;
- measurement->variant.air_quality_metrics.particles_tps = sen5xmeasurement.tSize;
- }
-
- if (model != SEN50) {
- if (sen5xmeasurement.humidity != FLT_MAX) {
- measurement->variant.air_quality_metrics.has_pm_humidity = true;
- measurement->variant.air_quality_metrics.pm_humidity = sen5xmeasurement.humidity;
- }
- if (sen5xmeasurement.temperature != FLT_MAX) {
- measurement->variant.air_quality_metrics.has_pm_temperature = true;
- measurement->variant.air_quality_metrics.pm_temperature = sen5xmeasurement.temperature;
- }
- if (sen5xmeasurement.vocIndex != FLT_MAX) {
- measurement->variant.air_quality_metrics.has_pm_voc_idx = true;
- measurement->variant.air_quality_metrics.pm_voc_idx = sen5xmeasurement.vocIndex;
- }
- }
-
- if (model == SEN55) {
- if (sen5xmeasurement.noxIndex != FLT_MAX) {
- measurement->variant.air_quality_metrics.has_pm_nox_idx = true;
- measurement->variant.air_quality_metrics.pm_nox_idx = sen5xmeasurement.noxIndex;
- }
- }
-
- return true;
- } else if (response == 1) {
- // TODO return because data was not ready yet
- // Should this return false?
- idle();
- return false;
- } else if (response == 2) {
- // Return with error for non-existing data
- idle();
- return false;
- }
-
- return true;
-}
-
-void SEN5XSensor::setMode(bool setOneShot)
-{
- oneShotMode = setOneShot;
- if (oneShotMode) {
- LOG_INFO("%s: set one shot mode", sensorName);
- } else {
- LOG_INFO("%s: set continuous mode", sensorName);
- }
-}
-
-AdminMessageHandleResult SEN5XSensor::handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request,
- meshtastic_AdminMessage *response)
-{
- AdminMessageHandleResult result;
- result = AdminMessageHandleResult::NOT_HANDLED;
-
- switch (request->which_payload_variant) {
- case meshtastic_AdminMessage_sensor_config_tag:
- if (!request->sensor_config.has_sen5x_config) {
- result = AdminMessageHandleResult::NOT_HANDLED;
- break;
- }
-
- // Check for one-shot/continuous mode request
- if (request->sensor_config.sen5x_config.has_set_one_shot_mode) {
- this->setMode(request->sensor_config.sen5x_config.set_one_shot_mode);
- }
-
- // TODO - Add admin command to set temperature offset?
- // Check for temperature offset
- // if (request->sensor_config.sen5x_config.has_set_temperature) {
- // this->setTemperature(request->sensor_config.sen5x_config.set_temperature);
- // }
-
- // TODO - Add admin command to trigger fan cleaning?
- // Check for one-shot/continuous mode request
- // if (request->sensor_config.sen5x_config.has_fan_cleaning && request->sensor_config.sen5x_config.fan_cleaning) {
- // this->startCleaning();
- // }
-
- result = AdminMessageHandleResult::HANDLED;
- break;
-
- default:
- result = AdminMessageHandleResult::NOT_HANDLED;
- }
-
- return result;
-}
-#endif
diff --git a/src/modules/Telemetry/Sensor/SEN5XSensor.h b/src/modules/Telemetry/Sensor/SEN5XSensor.h
index eeebbd373..2c8aaf524 100644
--- a/src/modules/Telemetry/Sensor/SEN5XSensor.h
+++ b/src/modules/Telemetry/Sensor/SEN5XSensor.h
@@ -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 + / 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
diff --git a/src/modules/Telemetry/Sensor/SEN6XSensor.h b/src/modules/Telemetry/Sensor/SEN6XSensor.h
new file mode 100644
index 000000000..ea624960e
--- /dev/null
+++ b/src/modules/Telemetry/Sensor/SEN6XSensor.h
@@ -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
diff --git a/src/modules/Telemetry/Sensor/SENXXSensor.cpp b/src/modules/Telemetry/Sensor/SENXXSensor.cpp
new file mode 100644
index 000000000..42b8a4ae3
--- /dev/null
+++ b/src/modules/Telemetry/Sensor/SENXXSensor.cpp
@@ -0,0 +1,1614 @@
+#include "configuration.h"
+
+#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
+
+#include "../mesh/generated/meshtastic/telemetry.pb.h"
+#include "FSCommon.h"
+#include "SENXXSensor.h"
+#include "SPILock.h"
+#include "SafeFile.h"
+#include "TelemetrySensor.h"
+#include // FLT_MAX
+#include
+#include
+#include // memcpy
+
+bool SENXXSensor::getVersion()
+{
+ if (!sendCommand(SENXX_GET_FIRMWARE_VERSION)) {
+ LOG_ERROR("%s: Error sending version command", sensorName);
+ return false;
+ }
+ delay(20); // From Sensirion Datasheet
+
+ // Version reply layout: fw major/minor, fw debug, hw major/minor,
+ // protocol major/minor, padding
+ uint8_t versionBuffer[SENXX_VERSION_BUFFER_SIZE]{};
+ size_t charNumber = readBuffer(&versionBuffer[0], SENXX_VERSION_BUFFER_SIZE + (SENXX_VERSION_BUFFER_SIZE / 2));
+ if (charNumber < SENXX_VERSION_BUFFER_SIZE) {
+ LOG_ERROR("%s: Error getting device version value", sensorName);
+ return false;
+ }
+
+ firmwareVer = versionBuffer[0] + (versionBuffer[1] / 10.0f);
+ hardwareVer = versionBuffer[3] + (versionBuffer[4] / 10.0f);
+ protocolVer = versionBuffer[5] + (versionBuffer[6] / 10.0f);
+
+ LOG_INFO("%s: Firmware Version: %0.2f", sensorName, firmwareVer);
+ LOG_INFO("%s: Hardware Version: %0.2f", sensorName, hardwareVer);
+ LOG_INFO("%s: Protocol Version: %0.2f", sensorName, protocolVer);
+
+ return true;
+}
+
+void SENXXSensor::updateCapabilities()
+{
+ hasRHT = hasVOC = hasNOx = hasCO2 = hasHCHO = false;
+ readMeasuredValuesCmd = 0;
+
+ switch (model) {
+ case SEN50:
+ break;
+ case SEN54:
+ hasRHT = true;
+ hasVOC = true;
+ break;
+ case SEN55:
+ hasRHT = true;
+ hasVOC = true;
+ hasNOx = true;
+ break;
+ case SEN62:
+ hasRHT = true;
+ readMeasuredValuesCmd = 0x04A3;
+ break;
+ case SEN63C:
+ hasRHT = true;
+ hasCO2 = true;
+ readMeasuredValuesCmd = 0x0471;
+ break;
+ case SEN65:
+ hasRHT = true;
+ hasVOC = true;
+ hasNOx = true;
+ readMeasuredValuesCmd = 0x0446;
+ break;
+ case SEN66:
+ hasRHT = true;
+ hasVOC = true;
+ hasNOx = true;
+ hasCO2 = true;
+ readMeasuredValuesCmd = 0x0300;
+ break;
+ case SEN68:
+ hasRHT = true;
+ hasVOC = true;
+ hasNOx = true;
+ hasHCHO = true;
+ readMeasuredValuesCmd = 0x0467;
+ break;
+ case SEN69C:
+ hasRHT = true;
+ hasVOC = true;
+ hasNOx = true;
+ hasHCHO = true;
+ hasCO2 = true;
+ readMeasuredValuesCmd = 0x04B5;
+ break;
+ default:
+ break;
+ }
+}
+
+bool SENXXSensor::findModel()
+{
+ if (!sendCommand(SENXX_GET_PRODUCT_NAME)) {
+ LOG_ERROR("%s: Error asking for product name", sensorName);
+ return false;
+ }
+ delay(50); // From Sensirion Datasheet
+
+ uint8_t name[SENXX_PRODUCT_NAME_BUFFER_SIZE]{};
+ size_t charNumber = readBuffer(&name[0], SENXX_PRODUCT_NAME_BUFFER_SIZE + (SENXX_PRODUCT_NAME_BUFFER_SIZE / 2));
+
+ if (charNumber < SENXX_PRODUCT_NAME_BUFFER_SIZE) {
+ LOG_ERROR("%s: Error getting device name", sensorName);
+ return false;
+ }
+
+ // Every model's product name follows "SEN[C]",
+ // e.g. "SEN50", "SEN55", "SEN63C", "SEN69C" - so name[3] picks the family
+ // (SEN5X vs SEN6X) and name[4] picks the exact variant within it.
+ model = SENXX_UNKNOWN;
+ if (name[3] == '5') {
+ switch (name[4]) {
+ case '0':
+ model = SEN50;
+ break;
+ case '4':
+ model = SEN54;
+ break;
+ case '5':
+ model = SEN55;
+ break;
+ }
+ } else if (name[3] == '6') {
+ switch (name[4]) {
+ case '2':
+ model = SEN62;
+ break;
+ case '3':
+ model = SEN63C;
+ break;
+ case '5':
+ model = SEN65;
+ break;
+ case '6':
+ model = SEN66;
+ break;
+ case '8':
+ model = SEN68;
+ break;
+ case '9':
+ model = SEN69C;
+ break;
+ }
+ }
+
+ if (model == SENXX_UNKNOWN) {
+ return false;
+ }
+
+ updateCapabilities();
+ LOG_INFO("%s: found sensor model %s", sensorName, (const char *)name);
+ return true;
+}
+
+bool SENXXSensor::probe(TwoWire *bus, uint8_t address, ScanI2C::I2CPort port)
+{
+ LOG_INFO("%s: probing sensor", sensorName);
+
+ _bus = bus;
+ _address = address;
+
+#ifdef SENXX_I2C_CLOCK_SPEED
+ _port = port;
+ reClockI2C.setup(_bus, _port);
+#endif /* SENXX_I2C_CLOCK_SPEED */
+
+ if (!findModel()) {
+ LOG_DEBUG("%s: can't find sensor model", sensorName);
+ return false;
+ }
+
+ return true;
+}
+
+bool SENXXSensor::sendCommand(uint16_t command)
+{
+ uint8_t nothing;
+ return sendCommand(command, ¬hing, 0);
+}
+
+bool SENXXSensor::sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNumber)
+{
+ // At least we need two bytes for the command
+ uint8_t bufferSize = 2;
+
+ // Add space for CRC bytes (one every two bytes)
+ if (byteNumber > 0)
+ bufferSize += byteNumber + (byteNumber / 2);
+
+ uint8_t toSend[bufferSize];
+ uint8_t i = 0;
+ toSend[i++] = static_cast((command & 0xFF00) >> 8);
+ toSend[i++] = static_cast((command & 0x00FF) >> 0);
+
+ // Prepare buffer with CRC every third byte
+ uint8_t bi = 0;
+ if (byteNumber > 0) {
+ while (bi < byteNumber) {
+ toSend[i++] = buffer[bi++];
+ toSend[i++] = buffer[bi++];
+ uint8_t calcCRC = senxxCRC(&buffer[bi - 2]);
+ toSend[i++] = calcCRC;
+ }
+ }
+
+#ifdef SENXX_I2C_CLOCK_SPEED
+ LOG_DEBUG("%s: Attempting to reclock speed to %uHz", sensorName, SENXX_I2C_CLOCK_SPEED);
+ reClockI2C.setClock(SENXX_I2C_CLOCK_SPEED);
+#endif /* SENXX_I2C_CLOCK_SPEED */
+
+ // Transmit the data
+ // Note: this delay is necessary to allow for long-buffers
+ delay(20);
+ _bus->beginTransmission(_address);
+ size_t writtenBytes = _bus->write(toSend, bufferSize);
+ uint8_t i2c_error = _bus->endTransmission();
+
+#ifdef SENXX_I2C_CLOCK_SPEED
+ LOG_DEBUG("%s: restoring clock speed", sensorName);
+ reClockI2C.restoreClock();
+#endif /* SENXX_I2C_CLOCK_SPEED */
+
+ if (writtenBytes != bufferSize) {
+ LOG_ERROR("%s: Error writing on I2C bus", sensorName);
+ return false;
+ }
+
+ if (i2c_error != 0) {
+ LOG_ERROR("%s: Error on I2C communication: %x", sensorName, i2c_error);
+ return false;
+ }
+ return true;
+}
+
+uint8_t SENXXSensor::readBuffer(uint8_t *buffer, uint8_t byteNumber)
+{
+#ifdef SENXX_I2C_CLOCK_SPEED
+ LOG_DEBUG("%s: Attempting to reclock speed to %uHz", sensorName, SENXX_I2C_CLOCK_SPEED);
+ reClockI2C.setClock(SENXX_I2C_CLOCK_SPEED);
+#endif /* SENXX_I2C_CLOCK_SPEED */
+
+ size_t readBytes = _bus->requestFrom(_address, byteNumber);
+ if (readBytes != byteNumber) {
+ LOG_ERROR("%s: Error reading I2C bus", sensorName);
+#ifdef SENXX_I2C_CLOCK_SPEED
+ LOG_DEBUG("%s: restoring clock speed", sensorName);
+ reClockI2C.restoreClock();
+#endif /* SENXX_I2C_CLOCK_SPEED */
+ return 0;
+ }
+
+ uint8_t i = 0;
+ uint8_t receivedBytes = 0;
+ while (readBytes > 0) {
+ buffer[i++] = _bus->read(); // Just as a reminder: i++ returns i and after that increments.
+ buffer[i++] = _bus->read();
+ uint8_t recvCRC = _bus->read();
+ uint8_t calcCRC = senxxCRC(&buffer[i - 2]);
+ if (recvCRC != calcCRC) {
+ LOG_ERROR("%s: Checksum error while receiving msg", sensorName);
+#ifdef SENXX_I2C_CLOCK_SPEED
+ LOG_DEBUG("%s: restoring clock speed", sensorName);
+ reClockI2C.restoreClock();
+#endif /* SENXX_I2C_CLOCK_SPEED */
+ return 0;
+ }
+ readBytes -= 3;
+ receivedBytes += 2;
+ }
+
+#ifdef SENXX_I2C_CLOCK_SPEED
+ LOG_DEBUG("%s: restoring clock speed", sensorName);
+ reClockI2C.restoreClock();
+#endif /* SENXX_I2C_CLOCK_SPEED */
+
+ return receivedBytes;
+}
+
+uint8_t SENXXSensor::senxxCRC(const uint8_t *buffer)
+{
+ // This code is based on Sensirion's own implementation
+ // https://github.com/Sensirion/arduino-core/blob/41fd02cacf307ec4945955c58ae495e56809b96c/src/SensirionCrc.cpp
+ // Identical CRC8 (poly 0x31, init 0xFF) is used by the whole SEN5X/SEN6X family.
+ uint8_t crc = 0xff;
+
+ for (uint8_t i = 0; i < 2; i++) {
+
+ crc ^= buffer[i];
+
+ for (uint8_t bit = 8; bit > 0; bit--) {
+ if (crc & 0x80)
+ crc = (crc << 1) ^ 0x31;
+ else
+ crc = (crc << 1);
+ }
+ }
+
+ return crc;
+}
+
+void SENXXSensor::sleep()
+{
+ if (state == SENXX_CLEANING) {
+ // The scheduler's periodic "put idle-able sensors to sleep" housekeeping can reach
+ // here while a cleaning cycle is still running (isActive() reports SENXX_CLEANING as
+ // active). Don't let it interrupt the cycle - pendingForReadyMs()/finishCleaning()
+ // owns the transition out of SENXX_CLEANING.
+ LOG_INFO("%s: Not going to sleep, fan cleaning is in progress", sensorName);
+ return;
+ }
+ idle(true);
+}
+
+bool SENXXSensor::idle(bool checkState)
+{
+ // From the datasheet:
+ // By default, the VOC algorithm resets its state to initial
+ // values each time a measurement is started,
+ // even if the measurement was stopped only for a short
+ // time. So, the VOC index output value needs a long time
+ // until it is stable again. This can be avoided by
+ // restoring the previously memorized algorithm state before
+ // starting the measure mode
+
+ if (checkState) {
+ // If the stabilisation period is not passed for a model with a VOC sensor, don't go to idle
+ if (hasVOC) {
+ // Get VOC state before going to idle mode
+ vocValid = false;
+ if (vocStateFromSensor()) {
+ vocValid = vocStateValid();
+ // Check if we have time, and store it
+ uint32_t now; // If time is RTCQualityNone, it will return zero
+ now = getValidTime(RTCQuality::RTCQualityDevice);
+ // Check if state is valid (non-zero)
+ if (now) {
+ vocTime = now;
+ }
+ }
+
+ if (!(vocStateStable() && vocValid)) {
+ LOG_INFO("%s: Not stopping measurement, vocState is not stable yet!", sensorName);
+ return true;
+ }
+ }
+ // Save state and prefs (on all models)
+ saveState();
+ }
+
+ if (!oneShotMode) {
+ LOG_INFO("%s: Not stopping measurement, continuous mode!", sensorName);
+ return true;
+ } else {
+ LOG_INFO("%s: One shot mode enabled", sensorName);
+ }
+
+ // SEN6X has no low-power "RHT/Gas only" mode - it must always fully stop.
+ // Within SEN5X, models without gas sensing (SEN50) also fully stop; SEN54/SEN55
+ // instead switch to the RHT/Gas-only mode to keep the VOC engine warm.
+ // TODO - Decide if for variants with VOC/NOx sensor, the device will be kept on to avoid messing
+ // up with the engine. In principle, since we are giving the VOC state, the algorithm should work fine,
+ // however, from tests, we don't see the same.
+ // Recommendation: if it has VOC / NOx, suggest NOT to use oneShot mode
+ if (isSen6xFamily() || !hasVOC) {
+ if (!sendCommand(SENXX_STOP_MEASUREMENT)) {
+ LOG_ERROR("%s: Error stopping measurement", sensorName);
+ return false;
+ }
+ state = SENXX_IDLE;
+ LOG_INFO("%s: Stop measurement mode", sensorName);
+ } else {
+ if (!sendCommand(SEN5X_START_MEASUREMENT_RHT_GAS)) {
+ LOG_ERROR("%s: Error switching to RHT/Gas measurement", sensorName);
+ return false;
+ }
+ state = SENXX_RHTGAS_ONLY;
+ LOG_INFO("%s: Switch to RHT/Gas only measurement mode", sensorName);
+ }
+
+ delay(200); // From Sensirion Datasheet
+ pmMeasureStarted = 0;
+ return true;
+}
+
+bool SENXXSensor::vocStateRecent(uint32_t now)
+{
+ if (now) {
+ uint32_t passed = now - vocTime; // in seconds
+
+ // Check if state is recent, less than 10 minutes (600 seconds)
+ if (passed < SENXX_VOC_VALID_TIME && (now > SENXX_VOC_VALID_DATE)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+bool SENXXSensor::vocStateValid()
+{
+ if (!vocState[0] && !vocState[1] && !vocState[2] && !vocState[3] && !vocState[4] && !vocState[5] && !vocState[6] &&
+ !vocState[7]) {
+ LOG_DEBUG("%s: VOC state is all 0, invalid", sensorName);
+ return false;
+ } else {
+ LOG_DEBUG("%s: VOC state is valid", sensorName);
+ return true;
+ }
+}
+
+bool SENXXSensor::vocStateToSensor()
+{
+ if (!hasVOC) {
+ return true;
+ }
+
+ if (!vocStateValid()) {
+ LOG_INFO("%s: VOC state is invalid, not sending", sensorName);
+ return true;
+ }
+
+ if (!sendCommand(SENXX_STOP_MEASUREMENT)) {
+ LOG_ERROR("%s: Error stopping measurement", sensorName);
+ return false;
+ }
+ delay(200); // From Sensirion Datasheet
+
+ LOG_DEBUG("%s: Sending VOC state to sensor", sensorName);
+ LOG_DEBUG("[%u, %u, %u, %u, %u, %u, %u, %u]", vocState[0], vocState[1], vocState[2], vocState[3], vocState[4], vocState[5],
+ vocState[6], vocState[7]);
+
+ // Note: send command already takes into account the CRC
+ // buffer size increment needed
+ if (!sendCommand(SENXX_RW_VOCS_STATE, vocState, SENXX_VOC_STATE_BUFFER_SIZE)) {
+ LOG_ERROR("%s: Error sending VOC's state command", sensorName);
+ return false;
+ }
+
+ return true;
+}
+
+bool SENXXSensor::vocStateFromSensor()
+{
+ if (!hasVOC) {
+ return true;
+ }
+
+ LOG_INFO("%s: Getting VOC state from sensor", sensorName);
+ // Ask VOCs state from the sensor
+ if (!sendCommand(SENXX_RW_VOCS_STATE)) {
+ LOG_ERROR("%s: Error sending VOC's state command", sensorName);
+ return false;
+ }
+
+ delay(20); // From Sensirion Datasheet
+
+ // Retrieve the data into a staging buffer so a partial read (e.g. a CRC
+ // failure halfway through) cannot corrupt the current vocState.
+ // The requested size accounts for the CRC bytes
+ uint8_t stateBuffer[SENXX_VOC_STATE_BUFFER_SIZE]{};
+ size_t receivedNumber = readBuffer(&stateBuffer[0], SENXX_VOC_STATE_BUFFER_SIZE + (SENXX_VOC_STATE_BUFFER_SIZE / 2));
+ delay(20); // From Sensirion Datasheet
+
+ if (receivedNumber < SENXX_VOC_STATE_BUFFER_SIZE) {
+ LOG_DEBUG("%s: Error getting VOC's state", sensorName);
+ return false;
+ }
+ memcpy(vocState, stateBuffer, SENXX_VOC_STATE_BUFFER_SIZE);
+
+ // Print the state (if debug is on)
+ LOG_DEBUG("%s: VOC state retrieved from sensor: [%u, %u, %u, %u, %u, %u, %u, %u]", sensorName, vocState[0], vocState[1],
+ vocState[2], vocState[3], vocState[4], vocState[5], vocState[6], vocState[7]);
+
+ return true;
+}
+
+bool SENXXSensor::loadState()
+{
+#ifdef FSCom
+ spiLock->lock();
+ auto file = FSCom.open(senXXStateFileName, FILE_O_READ);
+ bool okay = false;
+ if (file) {
+ LOG_INFO("%s: state read from %s", sensorName, senXXStateFileName);
+
+ bool decoded;
+ uint32_t lastCleaningTime = 0;
+ bool lastCleaningValidFlag = false;
+ bool oneShot = true;
+ uint32_t vocStateTime = 0;
+ bool vocStateValidFlag = false;
+ uint64_t vocStateArray = 0;
+
+ if (isSen6xFamily()) {
+ pb_istream_t stream = {&readcb, &file, meshtastic_SEN6XState_size};
+ decoded = pb_decode(&stream, &meshtastic_SEN6XState_msg, &sen6xstate);
+ if (decoded) {
+ lastCleaningTime = sen6xstate.last_cleaning_time;
+ lastCleaningValidFlag = sen6xstate.last_cleaning_valid;
+ oneShot = sen6xstate.one_shot_mode;
+ vocStateTime = sen6xstate.voc_state_time;
+ vocStateValidFlag = sen6xstate.voc_state_valid;
+ vocStateArray = sen6xstate.voc_state_array;
+ } else {
+ LOG_ERROR("%s: can't decode protobuf %s", sensorName, PB_GET_ERROR(&stream));
+ }
+ } else {
+ pb_istream_t stream = {&readcb, &file, meshtastic_SEN5XState_size};
+ decoded = pb_decode(&stream, &meshtastic_SEN5XState_msg, &sen5xstate);
+ if (decoded) {
+ lastCleaningTime = sen5xstate.last_cleaning_time;
+ lastCleaningValidFlag = sen5xstate.last_cleaning_valid;
+ oneShot = sen5xstate.one_shot_mode;
+ vocStateTime = sen5xstate.voc_state_time;
+ vocStateValidFlag = sen5xstate.voc_state_valid;
+ vocStateArray = sen5xstate.voc_state_array;
+ } else {
+ LOG_ERROR("%s: can't decode protobuf %s", sensorName, PB_GET_ERROR(&stream));
+ }
+ }
+
+ if (decoded) {
+ lastCleaning = lastCleaningTime;
+ lastCleaningValid = lastCleaningValidFlag;
+ oneShotMode = oneShot;
+
+ if (hasVOC) {
+ vocTime = vocStateTime;
+ vocValid = vocStateValidFlag;
+ // Unpack state
+ vocState[7] = (uint8_t)(vocStateArray >> 56);
+ vocState[6] = (uint8_t)(vocStateArray >> 48);
+ vocState[5] = (uint8_t)(vocStateArray >> 40);
+ vocState[4] = (uint8_t)(vocStateArray >> 32);
+ vocState[3] = (uint8_t)(vocStateArray >> 24);
+ vocState[2] = (uint8_t)(vocStateArray >> 16);
+ vocState[1] = (uint8_t)(vocStateArray >> 8);
+ vocState[0] = (uint8_t)vocStateArray;
+ }
+
+ okay = true;
+ }
+ file.close();
+ } else {
+ LOG_INFO("%s: No state found (File: %s)", sensorName, senXXStateFileName);
+ }
+ spiLock->unlock();
+ return okay;
+#else
+ LOG_ERROR("%s: Filesystem not implemented", sensorName);
+ return false;
+#endif
+}
+
+bool SENXXSensor::saveState()
+{
+#ifdef FSCom
+ auto file = SafeFile(senXXStateFileName);
+
+ // Pack VOC state (8 bytes)
+ uint64_t vocStateArray = (((uint64_t)vocState[7]) << 56) | ((uint64_t)vocState[6] << 48) | ((uint64_t)vocState[5] << 40) |
+ ((uint64_t)vocState[4] << 32) | ((uint64_t)vocState[3] << 24) | ((uint64_t)vocState[2] << 16) |
+ ((uint64_t)vocState[1] << 8) | ((uint64_t)vocState[0]);
+
+ bool encoded;
+ LOG_INFO("%s: state write to %s", sensorName, senXXStateFileName);
+
+ if (isSen6xFamily()) {
+ sen6xstate.last_cleaning_time = lastCleaning;
+ sen6xstate.last_cleaning_valid = lastCleaningValid;
+ sen6xstate.one_shot_mode = oneShotMode;
+
+ if (hasVOC) {
+ sen6xstate.has_voc_state_time = true;
+ sen6xstate.has_voc_state_valid = true;
+ sen6xstate.has_voc_state_array = true;
+ sen6xstate.voc_state_time = vocTime;
+ sen6xstate.voc_state_valid = vocValid;
+ sen6xstate.voc_state_array = vocStateArray;
+ }
+
+ pb_ostream_t stream = {&writecb, static_cast(&file), meshtastic_SEN6XState_size};
+ encoded = pb_encode(&stream, &meshtastic_SEN6XState_msg, &sen6xstate);
+ if (!encoded)
+ LOG_ERROR("%s: can't encode protobuf %s", sensorName, PB_GET_ERROR(&stream));
+ } else {
+ sen5xstate.last_cleaning_time = lastCleaning;
+ sen5xstate.last_cleaning_valid = lastCleaningValid;
+ sen5xstate.one_shot_mode = oneShotMode;
+
+ if (hasVOC) {
+ sen5xstate.has_voc_state_time = true;
+ sen5xstate.has_voc_state_valid = true;
+ sen5xstate.has_voc_state_array = true;
+ sen5xstate.voc_state_time = vocTime;
+ sen5xstate.voc_state_valid = vocValid;
+ sen5xstate.voc_state_array = vocStateArray;
+ }
+
+ pb_ostream_t stream = {&writecb, static_cast(&file), meshtastic_SEN5XState_size};
+ encoded = pb_encode(&stream, &meshtastic_SEN5XState_msg, &sen5xstate);
+ if (!encoded)
+ LOG_ERROR("%s: can't encode protobuf %s", sensorName, PB_GET_ERROR(&stream));
+ }
+
+ bool okay = encoded;
+ okay &= file.close();
+
+ if (okay)
+ LOG_INFO("%s: state write to %s successful", sensorName, senXXStateFileName);
+
+ return okay;
+#else
+ LOG_ERROR("%s: Filesystem not implemented", sensorName);
+ return false;
+#endif
+}
+
+bool SENXXSensor::isActive()
+{
+ // SENXX_CLEANING counts as active so the scheduler polls pendingForReadyMs()
+ // (which drives the cleaning cycle to completion) instead of calling wakeUp() again.
+ return state == SENXX_MEASUREMENT || state == SENXX_MEASUREMENT_2 || state == SENXX_CLEANING;
+}
+
+bool SENXXSensor::checkRTCQualityImproved()
+{
+ RTCQuality currentQuality = getRTCQuality();
+ if (currentQuality == lastRTCQuality) {
+ return false;
+ }
+ LOG_DEBUG("%s: RTC quality changed: %s -> %s", sensorName, RtcName(lastRTCQuality), RtcName(currentQuality));
+ bool gainedUsableClock = lastRTCQuality < RTCQuality::RTCQualityDevice && currentQuality >= RTCQuality::RTCQualityDevice;
+ lastRTCQuality = currentQuality;
+ return gainedUsableClock;
+}
+
+void SENXXSensor::reconcileTimeDependentState(uint32_t now)
+{
+ if (lastCleaningValid) {
+ int32_t passed = now - lastCleaning; // in seconds
+
+ if (passed > ONE_WEEK_IN_SECONDS && (now > SENXX_VOC_VALID_DATE)) {
+ // If current date greater than 01/01/2018 (validity check)
+ LOG_INFO("%s: More than a week (%us) since last cleaning in epoch (%us). Trigger, cleaning...", sensorName, passed,
+ lastCleaning);
+ startCleaning();
+ } else {
+ LOG_INFO("%s: Cleaning not needed (%ds passed). Last cleaning date (in epoch): %us", sensorName, passed,
+ lastCleaning);
+ }
+ } else {
+ // We assume the device has just been updated or it is new,
+ // so no need to trigger a cleaning.
+ // Just save the timestamp to do a cleaning one week from now.
+ // Otherwise, we will never trigger cleaning in some cases
+ lastCleaning = now;
+ lastCleaningValid = true;
+ LOG_INFO("%s: No valid last cleaning date found, saving it now: %us", sensorName, lastCleaning);
+ saveState();
+ }
+
+ if (hasVOC) {
+ if (!vocValid) {
+ LOG_INFO("%s: No valid VOC's state found", sensorName);
+ } else {
+ // Check if state is recent
+ if (vocStateRecent(now)) {
+ // If current date greater than 01/01/2018 (validity check)
+ // Send it to the sensor
+ LOG_INFO("%s: VOC state is valid and recent", sensorName);
+ vocStateToSensor();
+ } else {
+ LOG_INFO("%s: VOC state is too old or date is invalid", sensorName);
+ LOG_DEBUG("%s: vocTime %u, and now %u", sensorName, vocTime, now);
+ }
+ }
+ }
+}
+
+uint32_t SENXXSensor::wakeUp()
+{
+
+ LOG_DEBUG("%s: Waking up sensor", sensorName);
+
+ // The RTC may not have had a valid time when we last checked (e.g. right after boot,
+ // before a WiFi/GPS/phone time source connected). Each wake is a natural, frequent point
+ // to notice that it has since become valid and reconcile the saved cleaning/VOC state
+ // against real elapsed time, instead of only ever checking once in initDevice().
+ if (checkRTCQualityImproved()) {
+ uint32_t now = getValidTime(RTCQuality::RTCQualityDevice);
+ if (now) {
+ LOG_INFO("%s: RTC became available (%s), reconciling saved cleaning/VOC state", sensorName, RtcName(lastRTCQuality));
+ reconcileTimeDependentState(now);
+ if (state == SENXX_CLEANING) {
+ // A cleaning cycle was just started; let it run its course via
+ // pendingForReadyMs() instead of overwriting state with the
+ // measurement-start logic below.
+ return SENXX_CLEANING_DURATION_MS;
+ }
+ }
+ }
+
+ if (!sendCommand(SENXX_START_MEASUREMENT)) {
+ LOG_ERROR("%s: Error starting measurement", sensorName);
+ // TODO - what should this return?? Something actually on the default interval?
+ return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS;
+ }
+ delay(50); // From Sensirion Datasheet
+
+ pmMeasureStarted = millis();
+ state = SENXX_MEASUREMENT;
+ LOG_INFO("%s: Started measurement mode", sensorName);
+ return SENXX_PM_WARMUP_MS_1;
+}
+
+bool SENXXSensor::vocStateStable()
+{
+ uint32_t sinceFirstMeasureStarted = (millis() - rhtGasMeasureStarted) / 1000;
+ LOG_DEBUG("%s: sinceFirstMeasureStarted: %us", sensorName, sinceFirstMeasureStarted);
+ return sinceFirstMeasureStarted > SENXX_VOC_STATE_WARMUP_S;
+}
+
+bool SENXXSensor::startCleaning()
+{
+ // Note: we only should enter here if we have a valid RTC with at least
+ // RTCQuality::RTCQualityDevice
+ SENXXState previousState = state;
+ state = SENXX_CLEANING;
+
+ // Note that cleaning command can only be run when the sensor is in measurement mode
+ if (!sendCommand(SENXX_START_MEASUREMENT)) {
+ LOG_ERROR("%s: Error starting measurement mode", sensorName);
+ state = previousState;
+ return false;
+ }
+ delay(50); // From Sensirion Datasheet
+
+ if (!sendCommand(SENXX_START_FAN_CLEANING)) {
+ LOG_ERROR("%s: Error starting fan cleaning", sensorName);
+ state = previousState;
+ return false;
+ }
+ delay(20); // From Sensirion Datasheet
+
+ // This message will be always printed so the user knows the device it's not hung
+ LOG_INFO("%s: Started fan cleaning it will take 10 seconds...", sensorName);
+
+ // Don't block the caller for the ~10.5s the cycle takes - pendingForReadyMs()
+ // polls SENXX_CLEANING and calls finishCleaning() once it's done.
+ cleaningStarted = millis();
+ return true;
+}
+
+void SENXXSensor::finishCleaning()
+{
+ LOG_INFO("%s: Cleaning done", sensorName);
+
+ // Save timestamp in flash so we know when a week has passed
+ uint32_t now;
+ now = getValidTime(RTCQuality::RTCQualityDevice);
+ if (now) {
+ lastCleaning = now;
+ lastCleaningValid = true;
+ saveState();
+ }
+
+ idle();
+}
+
+bool SENXXSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev)
+{
+ state = SENXX_NOT_DETECTED;
+ LOG_INFO("%s: Init sensor", sensorName);
+
+ _bus = bus;
+ _address = dev->address.address;
+#ifdef SENXX_I2C_CLOCK_SPEED
+ _port = dev->address.port;
+ reClockI2C.setup(_bus, _port);
+#endif /* SENXX_I2C_CLOCK_SPEED */
+
+ delay(50); // without this there is an error on the deviceReset function
+
+ if (!sendCommand(SENXX_RESET)) {
+ LOG_ERROR("%s: error resetting device", sensorName);
+ return false;
+ }
+ delay(200); // From Sensirion Datasheet
+
+ if (!findModel()) {
+ LOG_ERROR("%s: error finding sensor model", sensorName);
+ return false;
+ }
+
+ // Check the firmware version
+ if (!getVersion())
+ return false;
+ if (firmwareVer < 2) {
+ LOG_ERROR("%s: firmware is too old and will not work with this implementation", sensorName);
+ return false;
+ }
+ delay(200); // From Sensirion Datasheet
+
+ // Detection succeeded
+ state = SENXX_IDLE;
+ status = 1;
+
+ // Load state
+ loadState();
+
+ // Check if it is time to do a cleaning / whether the saved VOC state is still usable.
+ // This needs a real clock; if we don't have one yet (typical right after boot, before
+ // any time source has connected), don't lose the saved state - just defer the check.
+ // wakeUp() re-checks getRTCQuality() on every wake via checkRTCQualityImproved() and
+ // will run this same reconciliation the moment a valid time becomes available.
+ lastRTCQuality = getRTCQuality();
+ uint32_t now = getValidTime(RTCQuality::RTCQualityDevice);
+ if (now) {
+ reconcileTimeDependentState(now);
+ } else {
+ LOG_INFO("%s: Not enough RTCQuality yet, deferring saved cleaning/VOC state check until it improves", sensorName);
+ }
+
+ // If reconcileTimeDependentState() just started a cleaning cycle, leave state as
+ // SENXX_CLEANING - idle(false) would send SENXX_STOP_MEASUREMENT and clobber it
+ // mid-cycle. pendingForReadyMs() will poll it to completion once the scheduler starts.
+ rhtGasMeasureStarted = millis();
+ if (state != SENXX_CLEANING) {
+ idle(false);
+ }
+
+ initI2CSensor();
+ return true;
+}
+
+bool SENXXSensor::readValues()
+{
+ if (isSen6xFamily()) {
+ if (!sendCommand(readMeasuredValuesCmd)) {
+ LOG_ERROR("%s: Error sending read command", sensorName);
+ return false;
+ }
+ LOG_DEBUG("%s: Reading measured values", sensorName);
+ delay(20); // From Sensirion Datasheet
+
+ // Fixed field order per the SEN6x datasheet: PM1.0, PM2.5, PM4.0, PM10.0,
+ // [Humidity, Temperature], [VOC], [NOx], [HCHO], [CO2] - each block only
+ // present if the model supports it.
+ uint8_t wordCount = 4 + (hasRHT ? 2 : 0) + (hasVOC ? 1 : 0) + (hasNOx ? 1 : 0) + (hasHCHO ? 1 : 0) + (hasCO2 ? 1 : 0);
+ uint8_t dataBuffer[20]{};
+ size_t receivedNumber = readBuffer(&dataBuffer[0], wordCount * 3);
+ if (receivedNumber < (size_t)(wordCount * 2)) {
+ LOG_ERROR("%s: Error getting values", sensorName);
+ return false;
+ }
+
+ uint8_t idx = 0;
+ auto nextWord = [&dataBuffer, &idx]() -> int16_t {
+ int16_t v = static_cast((dataBuffer[idx] << 8) | dataBuffer[idx + 1]);
+ idx += 2;
+ return v;
+ };
+
+ uint16_t uint_pM1p0 = static_cast(nextWord());
+ uint16_t uint_pM2p5 = static_cast(nextWord());
+ uint16_t uint_pM4p0 = static_cast(nextWord());
+ uint16_t uint_pM10p0 = static_cast(nextWord());
+
+ // Map values the sensor reports as unavailable (SENXX_UINT_INVALID /
+ // SENXX_INT_INVALID) to the sentinels getMetrics() checks for
+ senxxmeasurement.pM1p0 = (uint_pM1p0 != SENXX_UINT_INVALID) ? (uint_pM1p0 / 10) : UINT16_MAX;
+ senxxmeasurement.pM2p5 = (uint_pM2p5 != SENXX_UINT_INVALID) ? (uint_pM2p5 / 10) : UINT16_MAX;
+ senxxmeasurement.pM4p0 = (uint_pM4p0 != SENXX_UINT_INVALID) ? (uint_pM4p0 / 10) : UINT16_MAX;
+ senxxmeasurement.pM10p0 = (uint_pM10p0 != SENXX_UINT_INVALID) ? (uint_pM10p0 / 10) : UINT16_MAX;
+
+ senxxmeasurement.humidity = FLT_MAX;
+ senxxmeasurement.temperature = FLT_MAX;
+ senxxmeasurement.vocIndex = FLT_MAX;
+ senxxmeasurement.noxIndex = FLT_MAX;
+ senxxmeasurement.hcho = FLT_MAX;
+ senxxmeasurement.co2 = FLT_MAX;
+
+ LOG_DEBUG("%s: Got readings: pM1p0=%u, pM2p5=%u, pM4p0=%u, pM10p0=%u", sensorName, senxxmeasurement.pM1p0,
+ senxxmeasurement.pM2p5, senxxmeasurement.pM4p0, senxxmeasurement.pM10p0);
+
+ if (hasRHT) {
+ int16_t int_humidity = nextWord();
+ int16_t int_temperature = nextWord();
+ senxxmeasurement.humidity = (int_humidity != SENXX_INT_INVALID) ? (int_humidity / 100.0f) : FLT_MAX;
+ senxxmeasurement.temperature = (int_temperature != SENXX_INT_INVALID) ? (int_temperature / 200.0f) : FLT_MAX;
+ LOG_DEBUG("%s: Got readings: humidity=%.2f, temperature=%.2f", sensorName, senxxmeasurement.humidity,
+ senxxmeasurement.temperature);
+ }
+ if (hasVOC) {
+ int16_t int_vocIndex = nextWord();
+ senxxmeasurement.vocIndex = (int_vocIndex != SENXX_INT_INVALID) ? (int_vocIndex / 10.0f) : FLT_MAX;
+ LOG_DEBUG("%s: Got readings: vocIndex=%.2f", sensorName, senxxmeasurement.vocIndex);
+ }
+ if (hasNOx) {
+ int16_t int_noxIndex = nextWord();
+ senxxmeasurement.noxIndex = (int_noxIndex != SENXX_INT_INVALID) ? (int_noxIndex / 10.0f) : FLT_MAX;
+ LOG_DEBUG("%s: Got readings: noxIndex=%.2f", sensorName, senxxmeasurement.noxIndex);
+ }
+ if (hasHCHO) {
+ uint16_t uint_hcho = static_cast(nextWord());
+ senxxmeasurement.hcho = (uint_hcho != SENXX_UINT_INVALID) ? (uint_hcho / 10.0f) : FLT_MAX;
+ LOG_DEBUG("%s: Got readings: HCHO=%.2f", sensorName, senxxmeasurement.hcho);
+ }
+ if (hasCO2) {
+ uint16_t uint_co2 = static_cast(nextWord());
+ senxxmeasurement.co2 = (uint_co2 != SENXX_UINT_INVALID) ? uint_co2 : FLT_MAX;
+ LOG_DEBUG("%s: Got readings: CO2=%.2f", sensorName, senxxmeasurement.co2);
+ }
+
+ return true;
+ }
+
+ // SEN5X always answers with the same fixed 8-word layout (PM1/2.5/4/10, humidity,
+ // temperature, VOC, NOx) regardless of model; unsupported fields simply come
+ // back as Sensirion's "value unknown" placeholders.
+ if (!sendCommand(SEN5X_READ_VALUES)) {
+ LOG_ERROR("%s: Error sending read command", sensorName);
+ return false;
+ }
+ LOG_DEBUG("%s: Reading PM Values", sensorName);
+ delay(20); // From Sensirion Datasheet
+
+ uint8_t dataBuffer[SEN5X_READ_VALUES_BUFFER_SIZE]{};
+ size_t receivedNumber = readBuffer(&dataBuffer[0], SEN5X_READ_VALUES_BUFFER_SIZE + (SEN5X_READ_VALUES_BUFFER_SIZE / 2));
+ if (receivedNumber < SEN5X_READ_VALUES_BUFFER_SIZE) {
+ LOG_ERROR("%s: Error getting values", sensorName);
+ return false;
+ }
+
+ // Get the integers
+ uint16_t uint_pM1p0 = static_cast((dataBuffer[0] << 8) | dataBuffer[1]);
+ uint16_t uint_pM2p5 = static_cast((dataBuffer[2] << 8) | dataBuffer[3]);
+ uint16_t uint_pM4p0 = static_cast((dataBuffer[4] << 8) | dataBuffer[5]);
+ uint16_t uint_pM10p0 = static_cast((dataBuffer[6] << 8) | dataBuffer[7]);
+
+ int16_t int_humidity = static_cast((dataBuffer[8] << 8) | dataBuffer[9]);
+ int16_t int_temperature = static_cast((dataBuffer[10] << 8) | dataBuffer[11]);
+ int16_t int_vocIndex = static_cast((dataBuffer[12] << 8) | dataBuffer[13]);
+ int16_t int_noxIndex = static_cast((dataBuffer[14] << 8) | dataBuffer[15]);
+
+ // Convert values based on Sensirion Arduino lib. Map values the sensor
+ // reports as unavailable (SENXX_UINT_INVALID / SENXX_INT_INVALID) to the
+ // sentinels getMetrics() checks for
+ senxxmeasurement.pM1p0 = (uint_pM1p0 != SENXX_UINT_INVALID) ? (uint_pM1p0 / 10) : UINT16_MAX;
+ senxxmeasurement.pM2p5 = (uint_pM2p5 != SENXX_UINT_INVALID) ? (uint_pM2p5 / 10) : UINT16_MAX;
+ senxxmeasurement.pM4p0 = (uint_pM4p0 != SENXX_UINT_INVALID) ? (uint_pM4p0 / 10) : UINT16_MAX;
+ senxxmeasurement.pM10p0 = (uint_pM10p0 != SENXX_UINT_INVALID) ? (uint_pM10p0 / 10) : UINT16_MAX;
+ senxxmeasurement.humidity = (int_humidity != SENXX_INT_INVALID) ? (int_humidity / 100.0f) : FLT_MAX;
+ senxxmeasurement.temperature = (int_temperature != SENXX_INT_INVALID) ? (int_temperature / 200.0f) : FLT_MAX;
+ senxxmeasurement.vocIndex = (int_vocIndex != SENXX_INT_INVALID) ? (int_vocIndex / 10.0f) : FLT_MAX;
+ senxxmeasurement.noxIndex = (int_noxIndex != SENXX_INT_INVALID) ? (int_noxIndex / 10.0f) : FLT_MAX;
+ senxxmeasurement.co2 = FLT_MAX;
+ senxxmeasurement.hcho = FLT_MAX;
+
+ LOG_DEBUG("%s: Got readings: pM1p0=%u, pM2p5=%u, pM4p0=%u, pM10p0=%u", sensorName, senxxmeasurement.pM1p0,
+ senxxmeasurement.pM2p5, senxxmeasurement.pM4p0, senxxmeasurement.pM10p0);
+
+ if (hasRHT) {
+ LOG_DEBUG("%s: Got readings: humidity=%.2f, temperature=%.2f, vocIndex=%.2f", sensorName, senxxmeasurement.humidity,
+ senxxmeasurement.temperature, senxxmeasurement.vocIndex);
+ }
+
+ if (hasNOx) {
+ LOG_DEBUG("%s: Got readings: noxIndex=%.2f", sensorName, senxxmeasurement.noxIndex);
+ }
+
+ return true;
+}
+
+bool SENXXSensor::readPNValues(bool cumulative)
+{
+ if (isSen6xFamily()) {
+ if (!sendCommand(SEN6X_READ_NUMBER_CONCENTRATION_VALUES)) {
+ LOG_ERROR("%s: Error sending read command", sensorName);
+ return false;
+ }
+
+ LOG_DEBUG("%s: Reading PN Values", sensorName);
+ delay(20); // From Sensirion Datasheet
+
+ uint8_t dataBuffer[10]{};
+ size_t receivedNumber = readBuffer(&dataBuffer[0], 15);
+ if (receivedNumber < 10) {
+ LOG_ERROR("%s: Error getting PN values", sensorName);
+ return false;
+ }
+
+ uint16_t uint_pN0p5 = static_cast((dataBuffer[0] << 8) | dataBuffer[1]);
+ uint16_t uint_pN1p0 = static_cast((dataBuffer[2] << 8) | dataBuffer[3]);
+ uint16_t uint_pN2p5 = static_cast((dataBuffer[4] << 8) | dataBuffer[5]);
+ uint16_t uint_pN4p0 = static_cast((dataBuffer[6] << 8) | dataBuffer[7]);
+ uint16_t uint_pN10p0 = static_cast((dataBuffer[8] << 8) | dataBuffer[9]);
+
+ // Raw PN values are #/cm3 with 0.1 resolution; multiplying by 10 converts
+ // to #/0.1l without the truncation of dividing first. Map values the
+ // sensor reports as unavailable (SENXX_UINT_INVALID) to the sentinel.
+ senxxmeasurement.pN0p5 = (uint_pN0p5 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN0p5 * 10) : UINT32_MAX;
+ senxxmeasurement.pN1p0 = (uint_pN1p0 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN1p0 * 10) : UINT32_MAX;
+ senxxmeasurement.pN2p5 = (uint_pN2p5 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN2p5 * 10) : UINT32_MAX;
+ senxxmeasurement.pN4p0 = (uint_pN4p0 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN4p0 * 10) : UINT32_MAX;
+ senxxmeasurement.pN10p0 = (uint_pN10p0 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN10p0 * 10) : UINT32_MAX;
+ // Unlike SEN5X's number-concentration command, SEN6X's doesn't return a
+ // "typical particle size" word.
+ senxxmeasurement.tSize = FLT_MAX;
+
+ // Remove accumulative values:
+ // https://github.com/fablabbcn/smartcitizen-kit-2x/issues/85
+ if (!cumulative) {
+ if (senxxmeasurement.pN10p0 != UINT32_MAX && senxxmeasurement.pN4p0 != UINT32_MAX)
+ senxxmeasurement.pN10p0 -= senxxmeasurement.pN4p0;
+ if (senxxmeasurement.pN4p0 != UINT32_MAX && senxxmeasurement.pN2p5 != UINT32_MAX)
+ senxxmeasurement.pN4p0 -= senxxmeasurement.pN2p5;
+ if (senxxmeasurement.pN2p5 != UINT32_MAX && senxxmeasurement.pN1p0 != UINT32_MAX)
+ senxxmeasurement.pN2p5 -= senxxmeasurement.pN1p0;
+ if (senxxmeasurement.pN1p0 != UINT32_MAX && senxxmeasurement.pN0p5 != UINT32_MAX)
+ senxxmeasurement.pN1p0 -= senxxmeasurement.pN0p5;
+ }
+
+ LOG_DEBUG("%s: Got readings: pN0p5=%u, pN1p0=%u, pN2p5=%u, pN4p0=%u, pN10p0=%u", sensorName, senxxmeasurement.pN0p5,
+ senxxmeasurement.pN1p0, senxxmeasurement.pN2p5, senxxmeasurement.pN4p0, senxxmeasurement.pN10p0);
+
+ return true;
+ }
+
+ if (!sendCommand(SEN5X_READ_PM_VALUES)) {
+ LOG_ERROR("%s: Error sending read command", sensorName);
+ return false;
+ }
+
+ LOG_DEBUG("%s: Reading PN Values", sensorName);
+ delay(20); // From Sensirion Datasheet
+
+ uint8_t dataBuffer[SEN5X_READ_PM_BUFFER_SIZE]{};
+ size_t receivedNumber = readBuffer(&dataBuffer[0], SEN5X_READ_PM_BUFFER_SIZE + (SEN5X_READ_PM_BUFFER_SIZE / 2));
+ if (receivedNumber < SEN5X_READ_PM_BUFFER_SIZE) {
+ LOG_ERROR("%s: Error getting PN values", sensorName);
+ return false;
+ }
+
+ // Get the integers
+ uint16_t uint_pN0p5 = static_cast((dataBuffer[8] << 8) | dataBuffer[9]);
+ uint16_t uint_pN1p0 = static_cast((dataBuffer[10] << 8) | dataBuffer[11]);
+ uint16_t uint_pN2p5 = static_cast((dataBuffer[12] << 8) | dataBuffer[13]);
+ uint16_t uint_pN4p0 = static_cast((dataBuffer[14] << 8) | dataBuffer[15]);
+ uint16_t uint_pN10p0 = static_cast((dataBuffer[16] << 8) | dataBuffer[17]);
+ uint16_t uint_tSize = static_cast((dataBuffer[18] << 8) | dataBuffer[19]);
+
+ // Convert values based on Sensirion Arduino lib. Raw PN values are #/cm3
+ // with 0.1 resolution; multiplying by 10 converts to #/0.1l without the
+ // truncation of dividing first. Map values the sensor reports as
+ // unavailable (SENXX_UINT_INVALID) to the sentinel getMetrics() checks for.
+ senxxmeasurement.pN0p5 = (uint_pN0p5 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN0p5 * 10) : UINT32_MAX;
+ senxxmeasurement.pN1p0 = (uint_pN1p0 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN1p0 * 10) : UINT32_MAX;
+ senxxmeasurement.pN2p5 = (uint_pN2p5 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN2p5 * 10) : UINT32_MAX;
+ senxxmeasurement.pN4p0 = (uint_pN4p0 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN4p0 * 10) : UINT32_MAX;
+ senxxmeasurement.pN10p0 = (uint_pN10p0 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN10p0 * 10) : UINT32_MAX;
+ senxxmeasurement.tSize = (uint_tSize != SENXX_UINT_INVALID) ? (uint_tSize / 1000.0f) : FLT_MAX;
+
+ // Remove accumuluative values:
+ // https://github.com/fablabbcn/smartcitizen-kit-2x/issues/85
+ if (!cumulative) {
+ if (senxxmeasurement.pN10p0 != UINT32_MAX && senxxmeasurement.pN4p0 != UINT32_MAX)
+ senxxmeasurement.pN10p0 -= senxxmeasurement.pN4p0;
+ if (senxxmeasurement.pN4p0 != UINT32_MAX && senxxmeasurement.pN2p5 != UINT32_MAX)
+ senxxmeasurement.pN4p0 -= senxxmeasurement.pN2p5;
+ if (senxxmeasurement.pN2p5 != UINT32_MAX && senxxmeasurement.pN1p0 != UINT32_MAX)
+ senxxmeasurement.pN2p5 -= senxxmeasurement.pN1p0;
+ if (senxxmeasurement.pN1p0 != UINT32_MAX && senxxmeasurement.pN0p5 != UINT32_MAX)
+ senxxmeasurement.pN1p0 -= senxxmeasurement.pN0p5;
+ }
+
+ LOG_DEBUG("%s: Got readings: pN0p5=%u, pN1p0=%u, pN2p5=%u, pN4p0=%u, pN10p0=%u, tSize=%.2f", sensorName,
+ senxxmeasurement.pN0p5, senxxmeasurement.pN1p0, senxxmeasurement.pN2p5, senxxmeasurement.pN4p0,
+ senxxmeasurement.pN10p0, senxxmeasurement.tSize);
+
+ return true;
+}
+
+uint8_t SENXXSensor::getMeasurements()
+{
+ uint32_t now = millis();
+
+ // Try to get new data
+ if (!sendCommand(SENXX_READ_DATA_READY)) {
+ LOG_ERROR("%s: Error sending command data ready flag", sensorName);
+ return 2;
+ }
+ delay(20); // From Sensirion Datasheet
+
+ uint8_t dataReadyBuffer[SENXX_DATA_READY_BUFFER_SIZE]{};
+ size_t charNumber = readBuffer(&dataReadyBuffer[0], SENXX_DATA_READY_BUFFER_SIZE + (SENXX_DATA_READY_BUFFER_SIZE / 2));
+ if (charNumber < SENXX_DATA_READY_BUFFER_SIZE) {
+ LOG_ERROR("%s: Error getting device version value", sensorName);
+ return 2;
+ }
+
+ bool dataReady = dataReadyBuffer[1];
+ uint32_t sinceLastDataPollMs = now - lastDataPoll;
+ // Check if data is ready, and if since last time we requested is less than SENXX_POLL_INTERVAL
+ if (!dataReady || (sinceLastDataPollMs < SENXX_POLL_INTERVAL)) {
+ LOG_INFO("%s: Data is not ready", sensorName);
+ return 1;
+ }
+
+ if (!readValues()) {
+ LOG_ERROR("%s: Error getting readings", sensorName);
+ return 2;
+ }
+
+ if (!readPNValues(false)) {
+ LOG_ERROR("%s: Error getting PN readings", sensorName);
+ return 2;
+ }
+
+ lastDataPoll = now;
+
+ return 0;
+}
+
+int32_t SENXXSensor::wakeUpTimeMs()
+{
+ return SENXX_PM_WARMUP_MS_2;
+}
+
+int32_t SENXXSensor::pendingForReadyMs()
+{
+ uint32_t now = millis();
+ uint32_t sincePmMeasureStarted = now - pmMeasureStarted;
+ LOG_DEBUG("%s: Since measure started: %ums", sensorName, sincePmMeasureStarted);
+
+ switch (state) {
+ case SENXX_MEASUREMENT: {
+
+ if (!pmMeasureStarted) {
+ pmMeasureStarted = now;
+ }
+
+ if (sincePmMeasureStarted < SENXX_PM_WARMUP_MS_1) {
+ LOG_INFO("%s: not enough time passed since starting measurement", sensorName);
+ return SENXX_PM_WARMUP_MS_1 - sincePmMeasureStarted;
+ }
+
+ // Get PN values to check if we are above or below threshold
+ readPNValues(true);
+ lastDataPoll = now;
+
+ // If the reading is low (the threshold is in #/cm3) and second warmUp hasn't passed we return to come back later
+ if ((senxxmeasurement.pN4p0 / 100) < SENXX_PN4P0_CONC_THD && sincePmMeasureStarted < SENXX_PM_WARMUP_MS_2) {
+ LOG_INFO("%s: Concentration is low, we will ask again in the second warm up period", sensorName);
+ state = SENXX_MEASUREMENT_2;
+ // Report how many seconds are pending to cover the first warm up period
+ return SENXX_PM_WARMUP_MS_2 - sincePmMeasureStarted;
+ }
+ // CO2 sensor has an additional warmup time
+ if (hasCO2 && sincePmMeasureStarted < SEN6X_CO2_WARMUP_MS) {
+ return SEN6X_CO2_WARMUP_MS - sincePmMeasureStarted;
+ }
+ return 0;
+ }
+ case SENXX_MEASUREMENT_2: {
+ if (sincePmMeasureStarted < SENXX_PM_WARMUP_MS_2) {
+ // Report how many seconds are pending to cover the first warm up period
+ return SENXX_PM_WARMUP_MS_2 - sincePmMeasureStarted;
+ }
+ return 0;
+ }
+ case SENXX_CLEANING: {
+ uint32_t sinceCleaningStarted = now - cleaningStarted;
+ if (sinceCleaningStarted < SENXX_CLEANING_DURATION_MS) {
+ return SENXX_CLEANING_DURATION_MS - sinceCleaningStarted;
+ }
+ finishCleaning();
+ return 0;
+ }
+ default: {
+ return -1;
+ }
+ }
+}
+
+bool SENXXSensor::getMetrics(meshtastic_Telemetry *measurement)
+{
+ LOG_INFO("%s: Attempting to get metrics", sensorName);
+ if (!isActive()) {
+ LOG_INFO("%s: not in measurement mode", sensorName);
+ return false;
+ }
+
+ uint8_t response;
+ response = getMeasurements();
+
+ if (response == 0) {
+ if (senxxmeasurement.pM1p0 != UINT16_MAX) {
+ measurement->variant.air_quality_metrics.has_pm10_standard = true;
+ measurement->variant.air_quality_metrics.pm10_standard = senxxmeasurement.pM1p0;
+ }
+ if (senxxmeasurement.pM2p5 != UINT16_MAX) {
+ measurement->variant.air_quality_metrics.has_pm25_standard = true;
+ measurement->variant.air_quality_metrics.pm25_standard = senxxmeasurement.pM2p5;
+ }
+ if (senxxmeasurement.pM4p0 != UINT16_MAX) {
+ measurement->variant.air_quality_metrics.has_pm40_standard = true;
+ measurement->variant.air_quality_metrics.pm40_standard = senxxmeasurement.pM4p0;
+ }
+ if (senxxmeasurement.pM10p0 != UINT16_MAX) {
+ measurement->variant.air_quality_metrics.has_pm100_standard = true;
+ measurement->variant.air_quality_metrics.pm100_standard = senxxmeasurement.pM10p0;
+ }
+ if (senxxmeasurement.pN0p5 != UINT32_MAX) {
+ measurement->variant.air_quality_metrics.has_particles_05um = true;
+ measurement->variant.air_quality_metrics.particles_05um = senxxmeasurement.pN0p5;
+ }
+ if (senxxmeasurement.pN1p0 != UINT32_MAX) {
+ measurement->variant.air_quality_metrics.has_particles_10um = true;
+ measurement->variant.air_quality_metrics.particles_10um = senxxmeasurement.pN1p0;
+ }
+ if (senxxmeasurement.pN2p5 != UINT32_MAX) {
+ measurement->variant.air_quality_metrics.has_particles_25um = true;
+ measurement->variant.air_quality_metrics.particles_25um = senxxmeasurement.pN2p5;
+ }
+ if (senxxmeasurement.pN4p0 != UINT32_MAX) {
+ measurement->variant.air_quality_metrics.has_particles_40um = true;
+ measurement->variant.air_quality_metrics.particles_40um = senxxmeasurement.pN4p0;
+ }
+ if (senxxmeasurement.pN10p0 != UINT32_MAX) {
+ measurement->variant.air_quality_metrics.has_particles_100um = true;
+ measurement->variant.air_quality_metrics.particles_100um = senxxmeasurement.pN10p0;
+ }
+ if (senxxmeasurement.tSize != FLT_MAX) {
+ measurement->variant.air_quality_metrics.has_particles_tps = true;
+ measurement->variant.air_quality_metrics.particles_tps = senxxmeasurement.tSize;
+ }
+
+ if (hasRHT) {
+ if (senxxmeasurement.humidity != FLT_MAX) {
+ measurement->variant.air_quality_metrics.has_pm_humidity = true;
+ measurement->variant.air_quality_metrics.pm_humidity = senxxmeasurement.humidity;
+ }
+ if (senxxmeasurement.temperature != FLT_MAX) {
+ measurement->variant.air_quality_metrics.has_pm_temperature = true;
+ measurement->variant.air_quality_metrics.pm_temperature = senxxmeasurement.temperature;
+ }
+ }
+
+ if (hasVOC && senxxmeasurement.vocIndex != FLT_MAX) {
+ measurement->variant.air_quality_metrics.has_pm_voc_idx = true;
+ measurement->variant.air_quality_metrics.pm_voc_idx = senxxmeasurement.vocIndex;
+ }
+
+ if (hasNOx && senxxmeasurement.noxIndex != FLT_MAX) {
+ measurement->variant.air_quality_metrics.has_pm_nox_idx = true;
+ measurement->variant.air_quality_metrics.pm_nox_idx = senxxmeasurement.noxIndex;
+ }
+
+ if (hasCO2 && senxxmeasurement.co2 != FLT_MAX) {
+ measurement->variant.air_quality_metrics.has_co2 = true;
+ measurement->variant.air_quality_metrics.co2 = (uint32_t)senxxmeasurement.co2;
+ }
+
+ if (hasHCHO && senxxmeasurement.hcho != FLT_MAX) {
+ measurement->variant.air_quality_metrics.has_form_formaldehyde = true;
+ measurement->variant.air_quality_metrics.form_formaldehyde = senxxmeasurement.hcho;
+ }
+
+ if (isSen6xFamily()) {
+ uint32_t statusFlags = 0;
+ if (readDeviceStatus(statusFlags)) {
+ measurement->variant.air_quality_metrics.has_pm_status_flags = true;
+ measurement->variant.air_quality_metrics.pm_status_flags = statusFlags;
+ logDeviceStatus(statusFlags);
+ }
+ }
+
+ return true;
+ } else if (response == 1) {
+ // TODO return because data was not ready yet
+ // Should this return false?
+ idle();
+ return false;
+ } else if (response == 2) {
+ // Return with error for non-existing data
+ idle();
+ return false;
+ }
+
+ return true;
+}
+
+bool SENXXSensor::readDeviceStatus(uint32_t &statusFlags)
+{
+ if (!isSen6xFamily()) {
+ return false;
+ }
+
+ if (!sendCommand(SEN6X_READ_DEVICE_STATUS)) {
+ LOG_ERROR("%s: Error sending read device status command", sensorName);
+ return false;
+ }
+ delay(20); // From Sensirion Datasheet
+
+ uint8_t dataBuffer[4]{};
+ size_t receivedNumber = readBuffer(&dataBuffer[0], 6);
+ if (receivedNumber == 0) {
+ LOG_ERROR("%s: Error getting device status", sensorName);
+ return false;
+ }
+
+ statusFlags = (static_cast(dataBuffer[0]) << 24) | (static_cast(dataBuffer[1]) << 16) |
+ (static_cast(dataBuffer[2]) << 8) | static_cast(dataBuffer[3]);
+ return true;
+}
+
+void SENXXSensor::logDeviceStatus(uint32_t statusFlags)
+{
+ if (statusFlags & SEN6X_STATUS_FAN_ERROR)
+ LOG_ERROR("%s: Fan error", sensorName);
+ if (statusFlags & SEN6X_STATUS_RHT_ERROR)
+ LOG_ERROR("%s: RH&T sensor error", sensorName);
+ if (statusFlags & SEN6X_STATUS_GAS_ERROR)
+ LOG_ERROR("%s: Gas (VOC/NOx) sensor error", sensorName);
+ if (statusFlags & SEN6X_STATUS_CO2_2_ERROR)
+ LOG_ERROR("%s: CO2 sensor error", sensorName);
+ if (statusFlags & SEN6X_STATUS_HCHO_ERROR)
+ LOG_ERROR("%s: Formaldehyde sensor error", sensorName);
+ if (statusFlags & SEN6X_STATUS_PM_ERROR)
+ LOG_ERROR("%s: PM sensor error", sensorName);
+ if (statusFlags & SEN6X_STATUS_CO2_1_ERROR)
+ LOG_ERROR("%s: CO2 sensor error", sensorName);
+ if (statusFlags & SEN6X_STATUS_FAN_SPEED_WARNING)
+ LOG_WARN("%s: Fan speed warning", sensorName);
+}
+
+bool SENXXSensor::setTemperatureOffset(float tempReference)
+{
+ if (!isSen6xFamily()) {
+ // No verified opcode for SEN5X's temperature offset command yet.
+ LOG_WARN("%s: Temperature offset not implemented for this model", sensorName);
+ return false;
+ }
+
+ if (senxxmeasurement.temperature == FLT_MAX) {
+ LOG_ERROR("%s: No recent temperature reading to calibrate against", sensorName);
+ return false;
+ }
+
+ float tempOffset = senxxmeasurement.temperature - tempReference;
+ LOG_INFO("%s: Setting temperature offset: %.2f (current=%.2f, reference=%.2f)", sensorName, tempOffset,
+ senxxmeasurement.temperature, tempReference);
+
+ // Payload: offset (int16, *200), slope (int16, *10000, 0=no change over time),
+ // time constant (uint16 seconds, 0=apply immediately), slot (uint16, 0=base self-heating).
+ int16_t offsetWord = static_cast(tempOffset * 200.0f);
+ uint8_t buffer[8]{
+ static_cast((offsetWord >> 8) & 0xFF),
+ static_cast(offsetWord & 0xFF),
+ 0,
+ 0, // slope = 0
+ 0,
+ 0, // time constant = 0 (apply immediately)
+ 0,
+ 0, // slot = 0
+ };
+
+ if (!sendCommand(SEN6X_GET_SET_TEMP_OFFSET, buffer, 8)) {
+ LOG_ERROR("%s: Error setting temperature offset", sensorName);
+ return false;
+ }
+
+ return true;
+}
+
+bool SENXXSensor::co2PerformFRC(uint32_t targetCO2ppm)
+{
+ if (!hasCO2) {
+ return false;
+ }
+
+ LOG_INFO("%s: Issuing FRC. Ensure device has been working at least 3 minutes in stable target environment", sensorName);
+ LOG_INFO("%s: Target CO2: %u ppm", sensorName, targetCO2ppm);
+
+ uint8_t buffer[2]{static_cast((targetCO2ppm >> 8) & 0xFF), static_cast(targetCO2ppm & 0xFF)};
+ if (!sendCommand(SEN6X_PERFORM_FORCED_CO2_RECAL, buffer, 2)) {
+ LOG_ERROR("%s: Error sending forced recalibration command", sensorName);
+ return false;
+ }
+ delay(500); // From Sensirion Datasheet
+
+ uint8_t resultBuffer[2]{};
+ if (readBuffer(&resultBuffer[0], 3) == 0) {
+ LOG_ERROR("%s: Error reading forced recalibration result", sensorName);
+ return false;
+ }
+
+ uint16_t correction = static_cast((resultBuffer[0] << 8) | resultBuffer[1]);
+ if (correction == 0xFFFF) {
+ LOG_ERROR("%s: Forced recalibration failed", sensorName);
+ return false;
+ }
+
+ LOG_INFO("%s: FRC correction successful. Correction output: %d ppm", sensorName, (int32_t)correction - 0x8000);
+ return true;
+}
+
+bool SENXXSensor::co2GetASC(bool &ascEnabled)
+{
+ if (!hasCO2) {
+ return false;
+ }
+
+ if (!sendCommand(SEN6X_GET_SET_CO2_ASC)) {
+ LOG_ERROR("%s: Error sending get ASC command", sensorName);
+ return false;
+ }
+ delay(20); // From Sensirion Datasheet
+
+ uint8_t buffer[2]{};
+ if (readBuffer(&buffer[0], 3) == 0) {
+ LOG_ERROR("%s: Error reading ASC status", sensorName);
+ return false;
+ }
+
+ ascEnabled = buffer[1] != 0;
+ LOG_INFO("%s: ASC is %s", sensorName, ascEnabled ? "enabled" : "disabled");
+ return true;
+}
+
+bool SENXXSensor::co2SetASC(bool ascEnabled)
+{
+ if (!hasCO2) {
+ return false;
+ }
+
+ LOG_INFO("%s: %s ASC", sensorName, ascEnabled ? "Enabling" : "Disabling");
+
+ uint8_t buffer[2]{0, static_cast(ascEnabled ? 1 : 0)};
+ if (!sendCommand(SEN6X_GET_SET_CO2_ASC, buffer, 2)) {
+ LOG_ERROR("%s: Error setting ASC", sensorName);
+ return false;
+ }
+ return true;
+}
+
+bool SENXXSensor::co2SetAltitude(uint32_t altitude)
+{
+ if (!hasCO2) {
+ return false;
+ }
+
+ LOG_INFO("%s: Setting altitude at %um (volatile - reverts on device reset)", sensorName, altitude);
+
+ uint16_t altitudeWord = static_cast(altitude);
+ uint8_t buffer[2]{static_cast((altitudeWord >> 8) & 0xFF), static_cast(altitudeWord & 0xFF)};
+ if (!sendCommand(SEN6X_GET_SET_ALTITUDE, buffer, 2)) {
+ LOG_ERROR("%s: Error setting altitude", sensorName);
+ return false;
+ }
+ return true;
+}
+
+bool SENXXSensor::co2SetAmbientPressure(uint32_t ambientPressurePa)
+{
+ if (!hasCO2) {
+ return false;
+ }
+
+ // The SEN6X command expects hPa (700-1200), while the admin config field
+ // matches SCD4X's Pa convention (70000-120000) for consistency across sensors.
+ uint16_t pressureHpa = static_cast(ambientPressurePa / 100);
+ LOG_INFO("%s: Setting ambient pressure at %u hPa (volatile - reverts on device reset)", sensorName, pressureHpa);
+
+ uint8_t buffer[2]{static_cast((pressureHpa >> 8) & 0xFF), static_cast(pressureHpa & 0xFF)};
+ if (!sendCommand(SEN6X_GET_SET_AMBIENT_PRESSURE, buffer, 2)) {
+ LOG_ERROR("%s: Error setting ambient pressure", sensorName);
+ return false;
+ }
+ return true;
+}
+
+bool SENXXSensor::co2FactoryReset()
+{
+ if (!hasCO2) {
+ return false;
+ }
+
+ LOG_INFO("%s: Requesting CO2 sensor factory reset", sensorName);
+ if (!sendCommand(SEN6X_CO2_FACTORY_RESET)) {
+ LOG_ERROR("%s: Error requesting CO2 factory reset", sensorName);
+ return false;
+ }
+ return true;
+}
+
+void SENXXSensor::setMode(bool setOneShot)
+{
+ oneShotMode = setOneShot;
+ if (oneShotMode) {
+ LOG_INFO("%s: setting mode to one shot mode", sensorName);
+ } else {
+ LOG_INFO("%s: setting mode to continuous mode", sensorName);
+ }
+}
+
+AdminMessageHandleResult SENXXSensor::handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request,
+ meshtastic_AdminMessage *response)
+{
+ AdminMessageHandleResult result;
+ result = AdminMessageHandleResult::NOT_HANDLED;
+
+ switch (request->which_payload_variant) {
+ case meshtastic_AdminMessage_sensor_config_tag: {
+ bool ok = true;
+ bool wasActive = isActive();
+
+ if (isSen6xFamily()) {
+ if (!request->sensor_config.has_sen6x_config) {
+ result = AdminMessageHandleResult::NOT_HANDLED;
+ break;
+ }
+ const auto &cfg = request->sensor_config.sen6x_config;
+
+ if (cfg.has_set_one_shot_mode) {
+ this->setMode(cfg.set_one_shot_mode);
+ }
+
+ if (cfg.has_start_fan_cleaning && cfg.start_fan_cleaning) {
+ ok &= this->startCleaning();
+ }
+
+ // FRC/ASC/altitude are only valid in idle mode (see SEN6X datasheet), and the
+ // temperature offset command doesn't need measurement running either - stop
+ // once, run every requested calibration step, then resume if we were active.
+ bool needsCalibration = cfg.has_set_temperature || cfg.has_set_asc || cfg.has_set_altitude ||
+ cfg.has_set_ambient_pressure || cfg.has_factory_reset;
+ if (needsCalibration && state == SENXX_CLEANING) {
+ // A fan cleaning was just started above (non-blocking) - stopping measurement
+ // now would interrupt it. Calibration and cleaning can't be requested together;
+ // ask the caller to retry once the cleaning cycle completes.
+ LOG_WARN("%s: Skipping calibration request - fan cleaning in progress, retry once it completes", sensorName);
+ ok = false;
+ } else if (needsCalibration) {
+ if (wasActive) {
+ sendCommand(SENXX_STOP_MEASUREMENT);
+ delay(1400); // From Sensirion Datasheet
+ }
+
+ if (cfg.has_set_temperature) {
+ ok &= this->setTemperatureOffset(cfg.set_temperature);
+ }
+
+ if (hasCO2 &&
+ (cfg.has_set_asc || cfg.has_set_altitude || cfg.has_set_ambient_pressure || cfg.has_factory_reset)) {
+ Co2AdminRequest co2req;
+ // Matches SCD4X_config's own convention: presence of the field (not its
+ // value) is what requests a factory reset.
+ 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);
+ }
+
+ if (wasActive) {
+ this->wakeUp();
+ }
+ }
+ } else {
+ if (!request->sensor_config.has_sen5x_config) {
+ result = AdminMessageHandleResult::NOT_HANDLED;
+ break;
+ }
+ const auto &cfg = request->sensor_config.sen5x_config;
+
+ if (cfg.has_set_one_shot_mode) {
+ this->setMode(cfg.set_one_shot_mode);
+ }
+
+ if (cfg.has_start_fan_cleaning && cfg.start_fan_cleaning) {
+ ok &= this->startCleaning();
+ }
+ }
+
+ result = ok ? AdminMessageHandleResult::HANDLED : AdminMessageHandleResult::NOT_HANDLED;
+ break;
+ }
+
+ default:
+ result = AdminMessageHandleResult::NOT_HANDLED;
+ }
+
+ return result;
+}
+#endif
diff --git a/src/modules/Telemetry/Sensor/SENXXSensor.h b/src/modules/Telemetry/Sensor/SENXXSensor.h
new file mode 100644
index 000000000..1bc80efd8
--- /dev/null
+++ b/src/modules/Telemetry/Sensor/SENXXSensor.h
@@ -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 + / 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
diff --git a/src/modules/Telemetry/Sensor/SFA30Sensor.cpp b/src/modules/Telemetry/Sensor/SFA30Sensor.cpp
index aa19baf0f..90671e229 100644
--- a/src/modules/Telemetry/Sensor/SFA30Sensor.cpp
+++ b/src/modules/Telemetry/Sensor/SFA30Sensor.cpp
@@ -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) {
diff --git a/src/modules/Telemetry/Sensor/SFA30Sensor.h b/src/modules/Telemetry/Sensor/SFA30Sensor.h
index a72bef252..8894986a5 100644
--- a/src/modules/Telemetry/Sensor/SFA30Sensor.h
+++ b/src/modules/Telemetry/Sensor/SFA30Sensor.h
@@ -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;