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;