diff --git a/src/detect/ScanI2C.cpp b/src/detect/ScanI2C.cpp index 4795d2abc17..bf01a03657d 100644 --- a/src/detect/ScanI2C.cpp +++ b/src/detect/ScanI2C.cpp @@ -43,7 +43,7 @@ ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const ScanI2C::FoundDevice ScanI2C::firstAQI() const { - ScanI2C::DeviceType types[] = {PMSA003I, SCD4X}; + ScanI2C::DeviceType types[] = {PMSA003I, SEN5X, SCD4X}; return firstOfOrNONE(2, types); } diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp index 2ccb147c39d..aac398eb9c5 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.cpp +++ b/src/modules/Telemetry/AirQualityTelemetry.cpp @@ -22,6 +22,9 @@ #include "Sensor/AddI2CSensorTemplate.h" #include "Sensor/PMSA003ISensor.h" #include "Sensor/SEN5XSensor.h" +#if __has_include() +#include "Sensor/SCD4XSensor.h" +#endif void AirQualityTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) { @@ -44,6 +47,9 @@ 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); +#if __has_include() + addSensor(i2cScanner, ScanI2C::DeviceType::SCD4X); +#endif } int32_t AirQualityTelemetryModule::runOnce() @@ -186,7 +192,7 @@ void AirQualityTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSta const auto &m = telemetry.variant.air_quality_metrics; // Check if any telemetry field has valid data - bool hasAny = m.has_pm10_standard || m.has_pm25_standard || m.has_pm100_standard; + bool hasAny = m.has_pm10_standard || m.has_pm25_standard || m.has_pm100_standard || m.has_co2; if (!hasAny) { display->drawString(x, currentY, "No Telemetry"); @@ -213,6 +219,8 @@ void AirQualityTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSta entries.push_back("PM2.5: " + String(m.pm25_standard) + "ug/m3"); if (m.has_pm100_standard) entries.push_back("PM10: " + String(m.pm100_standard) + "ug/m3"); + if (m.has_co2) + entries.push_back("CO2: " + String(m.co2) + "ppm"); // === Show first available metric on top-right of first line === if (!entries.empty()) { @@ -256,6 +264,9 @@ bool AirQualityTelemetryModule::handleReceivedProtobuf(const meshtastic_MeshPack // LOG_INFO(" | PM1.0(Environmental)=%i, PM2.5(Environmental)=%i, PM10.0(Environmental)=%i", // t->variant.air_quality_metrics.pm10_environmental, t->variant.air_quality_metrics.pm25_environmental, // t->variant.air_quality_metrics.pm100_environmental); + + LOG_INFO(" | CO2=%i, CO2_T=%f, CO2_H=%f", t->variant.air_quality_metrics.co2, + t->variant.air_quality_metrics.co2_temperature, t->variant.air_quality_metrics.co2_humidity); #endif // release previous packet before occupying a new spot if (lastMeasurementPacket != nullptr) @@ -269,15 +280,20 @@ bool AirQualityTelemetryModule::handleReceivedProtobuf(const meshtastic_MeshPack bool AirQualityTelemetryModule::getAirQualityTelemetry(meshtastic_Telemetry *m) { - bool valid = true; + // Note: this is different to the case in EnvironmentTelemetryModule + // There, if any sensor fails to read - valid = false. + bool valid = false; bool hasSensor = false; m->time = getTime(); m->which_variant = meshtastic_Telemetry_air_quality_metrics_tag; m->variant.air_quality_metrics = meshtastic_AirQualityMetrics_init_zero; + bool sensor_get = false; for (TelemetrySensor *sensor : sensors) { LOG_DEBUG("Reading %s", sensor->sensorName); - valid = valid && sensor->getMetrics(m); + // Note - this function doesn't get properly called if within a conditional + sensor_get = sensor->getMetrics(m); + valid = valid || sensor_get; hasSensor = true; } @@ -319,12 +335,28 @@ bool AirQualityTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) m.time = getTime(); if (getAirQualityTelemetry(&m)) { - LOG_INFO("Send: pm10_standard=%u, pm25_standard=%u, pm100_standard=%u", m.variant.air_quality_metrics.pm10_standard, - m.variant.air_quality_metrics.pm25_standard, m.variant.air_quality_metrics.pm100_standard); - if (m.variant.air_quality_metrics.has_pm10_environmental) - LOG_INFO("pm10_environmental=%u, pm25_environmental=%u, pm100_environmental=%u", - m.variant.air_quality_metrics.pm10_environmental, m.variant.air_quality_metrics.pm25_environmental, - m.variant.air_quality_metrics.pm100_environmental); + + bool hasAnyPM = + m.variant.air_quality_metrics.has_pm10_standard || m.variant.air_quality_metrics.has_pm25_standard || + m.variant.air_quality_metrics.has_pm100_standard || m.variant.air_quality_metrics.has_pm10_environmental || + m.variant.air_quality_metrics.has_pm25_environmental || m.variant.air_quality_metrics.has_pm100_environmental; + + if (hasAnyPM) { + LOG_INFO("Send: pm10_standard=%u, pm25_standard=%u, pm100_standard=%u", m.variant.air_quality_metrics.pm10_standard, + m.variant.air_quality_metrics.pm25_standard, m.variant.air_quality_metrics.pm100_standard); + if (m.variant.air_quality_metrics.has_pm10_environmental) + LOG_INFO("pm10_environmental=%u, pm25_environmental=%u, pm100_environmental=%u", + m.variant.air_quality_metrics.pm10_environmental, m.variant.air_quality_metrics.pm25_environmental, + m.variant.air_quality_metrics.pm100_environmental); + } + + bool hasAnyCO2 = m.variant.air_quality_metrics.has_co2 || m.variant.air_quality_metrics.has_co2_temperature || + m.variant.air_quality_metrics.has_co2_humidity; + + if (hasAnyCO2) { + LOG_INFO("Send: co2=%i, co2_t=%f, co2_rh=%f", m.variant.air_quality_metrics.co2, + m.variant.air_quality_metrics.co2_temperature, m.variant.air_quality_metrics.co2_humidity); + } meshtastic_MeshPacket *p = allocDataProtobuf(m); p->to = dest; diff --git a/src/modules/Telemetry/Sensor/SCD4XSensor.cpp b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp new file mode 100644 index 00000000000..4f6e28b4b26 --- /dev/null +++ b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp @@ -0,0 +1,893 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR && __has_include() + +#include "../detect/reClockI2C.h" +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "SCD4XSensor.h" + +#define SCD4X_NO_ERROR 0 + +SCD4XSensor::SCD4XSensor() : TelemetrySensor(meshtastic_TelemetrySensorType_SCD4X, "SCD4X") {} + +bool SCD4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) +{ + LOG_INFO("Init sensor: %s", sensorName); + + _bus = bus; + _address = dev->address.address; + +#ifdef SCD4X_I2C_CLOCK_SPEED +#ifdef CAN_RECLOCK_I2C + uint32_t currentClock = reClockI2C(SCD4X_I2C_CLOCK_SPEED, _bus, false); +#elif !HAS_SCREEN + reClockI2C(SCD4X_I2C_CLOCK_SPEED, _bus, true); +#else + LOG_WARN("%s can't be used at this clock speed, with a screen", sensorName); + return false; +#endif /* CAN_RECLOCK_I2C */ +#endif /* SCD4X_I2C_CLOCK_SPEED */ + + scd4x.begin(*_bus, _address); + + // From SCD4X library + delay(30); + + // Stop periodic measurement + if (!stopMeasurement()) { +#if defined(SCD4X_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif + return false; + } + + // Get sensor variant + scd4x.getSensorVariant(sensorVariant); + + if (sensorVariant == SCD4X_SENSOR_VARIANT_SCD41) { + LOG_INFO("%s: Found SCD41", sensorName); + if (!powerUp()) { + LOG_ERROR("%s: Error trying to execute powerUp()", sensorName); +#if defined(SCD4X_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif + return false; + } + } + + if (!getASC(ascActive)) { + LOG_ERROR("%s: Unable to check if ASC is enabled", sensorName); +#if defined(SCD4X_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif + return false; + } + + // Start measurement in selected power mode (low power by default) + if (!startMeasurement()) { + LOG_ERROR("%s: Couldn't start measurement", sensorName); +#if defined(SCD4X_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif + return false; + } + +#if defined(SCD4X_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif + + if (state == SCD4X_MEASUREMENT) { + status = 1; + } else { + status = 0; + } + + initI2CSensor(); + + return true; +} + +bool SCD4XSensor::getMetrics(meshtastic_Telemetry *measurement) +{ + + if (state != SCD4X_MEASUREMENT) { + LOG_ERROR("%s: Not in measurement mode", sensorName); + return false; + } + + uint16_t co2, error; + float temperature, humidity; + +#ifdef SCD4X_I2C_CLOCK_SPEED +#ifdef CAN_RECLOCK_I2C + uint32_t currentClock = reClockI2C(SCD4X_I2C_CLOCK_SPEED, _bus, false); +#elif !HAS_SCREEN + reClockI2C(SCD4X_I2C_CLOCK_SPEED, _bus, true); +#else + LOG_WARN("%s can't be used at this clock speed, with a screen", sensorName); + return false; +#endif /* CAN_RECLOCK_I2C */ +#endif /* SCD4X_I2C_CLOCK_SPEED */ + + bool dataReady; + error = scd4x.getDataReadyStatus(dataReady); + if (!dataReady) { +#if defined(SCD4X_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif + LOG_ERROR("SCD4X: Data is not ready"); + return false; + } + + error = scd4x.readMeasurement(co2, temperature, humidity); + +#if defined(SCD4X_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif + + LOG_DEBUG("%s readings: %u ppm, %.2f degC, %.2f %rh", sensorName, co2, temperature, humidity); + if (error != SCD4X_NO_ERROR) { + LOG_DEBUG("%s: Error while getting measurements: %u", sensorName, error); + if (co2 == 0) { + LOG_ERROR("%s: Skipping invalid measurement.", sensorName); + } + return false; + } else { + measurement->variant.air_quality_metrics.has_co2_temperature = true; + measurement->variant.air_quality_metrics.has_co2_humidity = true; + measurement->variant.air_quality_metrics.has_co2 = true; + measurement->variant.air_quality_metrics.co2_temperature = temperature; + measurement->variant.air_quality_metrics.co2_humidity = humidity; + measurement->variant.air_quality_metrics.co2 = co2; + return true; + } +} + +/** + * @brief Perform a forced recalibration (FRC) of the CO₂ concentration. + * + * From Sensirion SCD4X I2C Library + * + * 1. Operate the SCD4x in the operation mode later used for normal sensor + * operation (e.g. periodic measurement) for at least 3 minutes in an + * environment with a homogenous and constant CO2 concentration. The sensor + * must be operated at the voltage desired for the application when + * performing the FRC sequence. 2. Issue the stop_periodic_measurement + * command. 3. Issue the perform_forced_recalibration command. + * @note This function should not change the clock + */ +bool SCD4XSensor::performFRC(uint32_t targetCO2) +{ + uint16_t error, frcCorr; + + LOG_INFO("%s: Issuing FRC. Ensure device has been working at least 3 minutes in stable target environment", sensorName); + + if (!stopMeasurement()) { + return false; + } + + LOG_INFO("%s: Target CO2: %u ppm", sensorName, targetCO2); + error = scd4x.performForcedRecalibration((uint16_t)targetCO2, frcCorr); + + // SCD4X Sensirion datasheet + delay(400); + + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to perform forced recalibration.", sensorName); + return false; + } + + if (frcCorr == 0xFFFF) { + LOG_ERROR("%s: Error while performing forced recalibration.", sensorName); + return false; + } + + LOG_INFO("%s: FRC Correction successful. Correction output: %u", sensorName, (uint16_t)(frcCorr - 0x8000)); + + return true; +} + +/** + * @brief Start measurement mode + * @note This function should not change the clock + */ +bool SCD4XSensor::startMeasurement() +{ + uint16_t error; + + if (state == SCD4X_MEASUREMENT) { + LOG_DEBUG("%s: Already in measurement mode", sensorName); + return true; + } + + if (lowPower) { + error = scd4x.startLowPowerPeriodicMeasurement(); + } else { + error = scd4x.startPeriodicMeasurement(); + } + + if (error == SCD4X_NO_ERROR) { + LOG_INFO("%s: Started measurement mode", sensorName); + if (lowPower) { + LOG_INFO("%s: Low power mode", sensorName); + } else { + LOG_INFO("%s: Normal power mode", sensorName); + } + + state = SCD4X_MEASUREMENT; + return true; + } else { + LOG_ERROR("%s: Couldn't start measurement mode", sensorName); + return false; + } +} + +/** + * @brief Stop measurement mode + * @note This function should not change the clock + */ +bool SCD4XSensor::stopMeasurement() +{ + uint16_t error; + + error = scd4x.stopPeriodicMeasurement(); + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to set idle mode on SCD4X.", sensorName); + return false; + } + + state = SCD4X_IDLE; + co2MeasureStarted = 0; + return true; +} + +/** + * @brief Set power mode + * Pass true to set low power mode + * @note This function should not change the clock + */ +bool SCD4XSensor::setPowerMode(bool _lowPower) +{ + lowPower = _lowPower; + + if (!stopMeasurement()) { + return false; + } + + if (lowPower) { + LOG_DEBUG("%s: Set low power mode", sensorName); + } else { + LOG_DEBUG("%s: Set normal power mode", sensorName); + } + + return true; +} + +/** + * @brief Check the current mode (ASC or FRC) + * From Sensirion SCD4X I2C Library + * @note This function should not change the clock + */ +bool SCD4XSensor::getASC(uint16_t &_ascActive) +{ + uint16_t error; + LOG_INFO("%s: Getting ASC", sensorName); + + if (!stopMeasurement()) { + return false; + } + error = scd4x.getAutomaticSelfCalibrationEnabled(_ascActive); + + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to send command.", sensorName); + return false; + } + + if (_ascActive) { + LOG_INFO("%s: ASC is enabled", sensorName); + } else { + LOG_INFO("%s: FRC is enabled", sensorName); + } + + return true; +} + +/** + * @brief Enable or disable automatic self calibration (ASC). + * + * From Sensirion SCD4X I2C Library + * + * Sets the current state (enabled / disabled) of the ASC. By default, ASC + * is enabled. + * @note This function should not change the clock + */ +bool SCD4XSensor::setASC(bool ascEnabled) +{ + uint16_t error; + + if (ascEnabled) { + LOG_INFO("%s: Enabling ASC", sensorName); + } else { + LOG_INFO("%s: Disabling ASC", sensorName); + } + + if (!stopMeasurement()) { + return false; + } + + error = scd4x.setAutomaticSelfCalibrationEnabled((uint16_t)ascEnabled); + + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to send command.", sensorName); + return false; + } + + error = scd4x.persistSettings(); + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to make settings persistent.", sensorName); + return false; + } + + if (!getASC(ascActive)) { + LOG_ERROR("%s: Unable to check if ASC is enabled", sensorName); + return false; + } + + if (ascActive) { + LOG_INFO("%s: ASC is enabled", sensorName); + } else { + LOG_INFO("%s: ASC is disabled", sensorName); + } + + return true; +} + +/** + * @brief Set the value of ASC baseline target in ppm. + * + * From Sensirion SCD4X I2C Library. + * + * Sets the value of the ASC baseline target, i.e. the CO₂ concentration in + * ppm which the ASC algorithm will assume as lower-bound background to + * which the SCD4x is exposed to regularly within one ASC period of + * operation. To save the setting to the EEPROM, the persist_settings + * command must be issued subsequently. The factory default value is 400 + * ppm. + * @note This function should not change the clock + */ +bool SCD4XSensor::setASCBaseline(uint32_t targetCO2) +{ + // TODO - Remove? + // Available in library, but not described in datasheet. + uint16_t error; + LOG_INFO("%s: Setting ASC baseline to: %u", sensorName, targetCO2); + + getASC(ascActive); + if (!ascActive) { + LOG_ERROR("%s: Can't set ASC baseline. ASC is not active", sensorName); + return false; + } + + if (!stopMeasurement()) { + return false; + } + + error = scd4x.setAutomaticSelfCalibrationTarget((uint16_t)targetCO2); + + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to send command.", sensorName); + return false; + } + + error = scd4x.persistSettings(); + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to make settings persistent.", sensorName); + return false; + } + + LOG_INFO("%s: Setting ASC baseline successful", sensorName); + + return true; +} + +/** + * @brief Set the temperature compensation reference. + * + * From Sensirion SCD4X I2C Library. + * + * Setting the temperature offset of the SCD4x inside the customer device + * allows the user to optimize the RH and T output signal. + * By default, the temperature offset is set to 4 °C. To save + * the setting to the EEPROM, the persist_settings command may be issued. + * Equation (1) details how the characteristic temperature offset can be + * calculated using the current temperature output of the sensor (TSCD4x), a + * reference temperature value (TReference), and the previous temperature + * offset (Toffset_pervious) obtained using the get_temperature_offset_raw + * command: + * + * Toffset_actual = TSCD4x - TReference + Toffset_pervious. + * + * Recommended temperature offset values are between 0 °C and 20 °C. The + * temperature offset does not impact the accuracy of the CO2 output. + * @note This function should not change the clock + */ +bool SCD4XSensor::setTemperature(float tempReference) +{ + uint16_t error; + float prevTempOffset; + float updatedTempOffset; + float tempOffset; + bool dataReady; + uint16_t co2; + float temperature; + float humidity; + + LOG_INFO("%s: Setting reference temperature at: %.2f", sensorName, tempReference); + + error = scd4x.getDataReadyStatus(dataReady); + if (!dataReady) { + LOG_ERROR("%s: Data is not ready", sensorName); + return false; + } + + error = scd4x.readMeasurement(co2, temperature, humidity); + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to read current temperature. Error code: %u", sensorName, error); + return false; + } + + LOG_INFO("%s: Current sensor temperature: %.2f", sensorName, temperature); + + if (!stopMeasurement()) { + return false; + } + + error = scd4x.getTemperatureOffset(prevTempOffset); + + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to get temperature offset. Error code: %u", sensorName, error); + return false; + } + LOG_INFO("%s: Current sensor temperature offset: %.2f", sensorName, prevTempOffset); + + tempOffset = temperature - tempReference + prevTempOffset; + + LOG_INFO("%s: Setting temperature offset: %.2f", sensorName, tempOffset); + error = scd4x.setTemperatureOffset(tempOffset); + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to set temperature offset. Error code: %u", sensorName, error); + return false; + } + + error = scd4x.persistSettings(); + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to make settings persistent. Error code: %u", sensorName, error); + return false; + } + + scd4x.getTemperatureOffset(updatedTempOffset); + LOG_INFO("%s: Updated sensor temperature offset: %.2f", sensorName, updatedTempOffset); + + return true; +} + +/** + * @brief Get the sensor altitude. + * + * From Sensirion SCD4X I2C Library. + * + * Altitude in meters above sea level can be set after device installation. + * Valid value between 0 and 3000m. This overrides pressure offset. + * @note This function should not change the clock + */ +bool SCD4XSensor::getAltitude(uint16_t &altitude) +{ + uint16_t error; + LOG_INFO("%s: Requesting sensor altitude", sensorName); + + if (!stopMeasurement()) { + return false; + } + + error = scd4x.getSensorAltitude(altitude); + + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to get altitude. Error code: %u", sensorName, error); + return false; + } + LOG_INFO("%s: Sensor altitude: %u", sensorName, altitude); + + return true; +} + +/** + * @brief Get the ambient pressure around the sensor. + * + * From Sensirion SCD4X I2C Library. + * + * Gets the ambient pressure in Pa. + * @note This function should not change the clock + */ +bool SCD4XSensor::getAmbientPressure(uint32_t &ambientPressure) +{ + uint16_t error; + LOG_INFO("%s: Requesting sensor ambient pressure", sensorName); + + error = scd4x.getAmbientPressure(ambientPressure); + + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to get altitude. Error code: %u", sensorName, error); + return false; + } + LOG_INFO("%s: Sensor ambient pressure: %u", sensorName, ambientPressure); + + return true; +} + +/** + * @brief Set the sensor altitude. + * + * From Sensirion SCD4X I2C Library. + * + * Altitude in meters above sea level can be set after device installation. + * Valid value between 0 and 3000m. This overrides pressure offset. + * @note This function should not change the clock + */ +bool SCD4XSensor::setAltitude(uint32_t altitude) +{ + uint16_t error; + + if (!stopMeasurement()) { + return false; + } + + error = scd4x.setSensorAltitude(altitude); + + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to set altitude. Error code: %u", sensorName, error); + return false; + } + + error = scd4x.persistSettings(); + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to make settings persistent. Error code: %u", sensorName, error); + return false; + } + + return true; +} + +/** + * @brief Set the ambient pressure around the sensor. + * + * From Sensirion SCD4X I2C Library. + * + * The set_ambient_pressure command can be sent during periodic measurements + * to enable continuous pressure compensation. Note that setting an ambient + * pressure overrides any pressure compensation based on a previously set + * sensor altitude. Use of this command is highly recommended for + * applications experiencing significant ambient pressure changes to ensure + * sensor accuracy. Valid input values are between 70000 - 120000 Pa. The + * default value is 101300 Pa. + * @note This function should not change the clock + */ +bool SCD4XSensor::setAmbientPressure(uint32_t ambientPressure) +{ + uint16_t error; + + error = scd4x.setAmbientPressure(ambientPressure); + + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to set altitude. Error code: %u", sensorName, error); + return false; + } + + // Sensirion doesn't indicate if this is necessary. We send it anyway + error = scd4x.persistSettings(); + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to make settings persistent. Error code: %u", sensorName, error); + return false; + } + + return true; +} + +/** + * @brief Perform factory reset to erase the settings stored in the EEPROM. + * + * From Sensirion SCD4X I2C Library. + * + * The perform_factory_reset command resets all configuration settings + * stored in the EEPROM and erases the FRC and ASC algorithm history. + * @note This function should not change the clock + */ +bool SCD4XSensor::factoryReset() +{ + uint16_t error; + + LOG_INFO("%s: Requesting factory reset", sensorName); + + if (!stopMeasurement()) { + return false; + } + + error = scd4x.performFactoryReset(); + + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to do factory reset. Error code: %u", sensorName, error); + return false; + } + + LOG_INFO("%s: Factory reset successful", sensorName); + + return true; +} + +/** + * @brief Put the sensor into sleep mode from idle mode. + * + * From Sensirion SCD4X I2C Library. + * + * Put the sensor from idle to sleep to reduce power consumption. Can be + * used to power down when operating the sensor in power-cycled single shot + * mode. + * @note This command is only available in idle mode. Only for SCD41. + */ +bool SCD4XSensor::powerDown() +{ + LOG_INFO("%s: Trying to send sensor to sleep", sensorName); + + if (sensorVariant != SCD4X_SENSOR_VARIANT_SCD41) { + LOG_WARN("SCD4X: Can't send sensor to sleep. Incorrect variant. Ignoring"); + return true; + } + +#ifdef SCD4X_I2C_CLOCK_SPEED +#ifdef CAN_RECLOCK_I2C + uint32_t currentClock = reClockI2C(SCD4X_I2C_CLOCK_SPEED, _bus, false); +#elif !HAS_SCREEN + reClockI2C(SCD4X_I2C_CLOCK_SPEED, _bus, true); +#else + LOG_WARN("%s can't be used at this clock speed, with a screen", sensorName); + return false; +#endif /* CAN_RECLOCK_I2C */ +#endif /* SCD4X_I2C_CLOCK_SPEED */ + + if (!stopMeasurement()) { +#if defined(SCD4X_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif + return false; + } + + if (scd4x.powerDown() != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Error trying to execute sleep()", sensorName); +#if defined(SCD4X_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif + return false; + } + +#if defined(SCD4X_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif + + state = SCD4X_OFF; + return true; +} + +/** + * @brief Wake up sensor from sleep mode to idle mode (powerUp) + * + * From Sensirion SCD4X I2C Library. + * + * Wake up the sensor from sleep mode into idle mode. Note that the SCD4x + * does not acknowledge the wake_up command. The sensor's idle state after + * wake up can be verified by reading out the serial number. + * @note This command is only available for SCD41. + * @note This function can't change clock (used in init) + */ +bool SCD4XSensor::powerUp() +{ + LOG_INFO("%s: Waking up", sensorName); + + if (scd4x.wakeUp() != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Error trying to execute wakeUp()", sensorName); + return false; + } + + state = SCD4X_IDLE; + + return true; +} + +/** + * @brief Check if sensor is in measurement mode + */ +bool SCD4XSensor::isActive() +{ + return state == SCD4X_MEASUREMENT; +} + +/** + * @brief Start measurement mode + * @note Not used in admin comands, getMetrics or init, can change clock. + */ +uint32_t SCD4XSensor::wakeUp() +{ + +#ifdef SCD4X_I2C_CLOCK_SPEED +#ifdef CAN_RECLOCK_I2C + uint32_t currentClock = reClockI2C(SCD4X_I2C_CLOCK_SPEED, _bus, false); +#elif !HAS_SCREEN + reClockI2C(SCD4X_I2C_CLOCK_SPEED, _bus, true); +#else + LOG_WARN("%s can't be used at this clock speed, with a screen", sensorName); + return 0; +#endif /* CAN_RECLOCK_I2C */ +#endif /* SCD4X_I2C_CLOCK_SPEED */ + + if (startMeasurement()) { + co2MeasureStarted = getTime(); +#if defined(SCD4X_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif + return SCD4X_WARMUP_MS; + } + +#if defined(SCD4X_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif + + return 0; +} + +/** + * @brief Stop measurement mode + * @note Not used in admin comands, getMetrics or init, can change clock. + */ +void SCD4XSensor::sleep() +{ +#ifdef SCD4X_I2C_CLOCK_SPEED +#ifdef CAN_RECLOCK_I2C + uint32_t currentClock = reClockI2C(SCD4X_I2C_CLOCK_SPEED, _bus, false); +#elif !HAS_SCREEN + reClockI2C(SCD4X_I2C_CLOCK_SPEED, _bus, true); +#else + LOG_WARN("%s can't be used at this clock speed, with a screen", sensorName); + return; +#endif /* CAN_RECLOCK_I2C */ +#endif /* SCD4X_I2C_CLOCK_SPEED */ + + stopMeasurement(); + +#if defined(SCD4X_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif +} + +/** + * @brief Can sleep function + * + * Power consumption is very low on lowPower mode, modify this function if + * you still want to override this behaviour. Otherwise, sleep is disabled + * routinely in low power mode + */ +bool SCD4XSensor::canSleep() +{ + return lowPower ? false : true; +} + +int32_t SCD4XSensor::wakeUpTimeMs() +{ + return SCD4X_WARMUP_MS; +} + +int32_t SCD4XSensor::pendingForReadyMs() +{ + uint32_t now; + now = getTime(); + uint32_t sinceCO2MeasureStarted = (now - co2MeasureStarted) * 1000; + LOG_DEBUG("%s: Since measure started: %ums", sensorName, sinceCO2MeasureStarted); + + if (sinceCO2MeasureStarted < SCD4X_WARMUP_MS) { + LOG_INFO("%s: not enough time passed since starting measurement", sensorName); + return SCD4X_WARMUP_MS - sinceCO2MeasureStarted; + } + return 0; +} + +AdminMessageHandleResult SCD4XSensor::handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) +{ + AdminMessageHandleResult result; + +#ifdef SCD4X_I2C_CLOCK_SPEED +#ifdef CAN_RECLOCK_I2C + uint32_t currentClock = reClockI2C(SCD4X_I2C_CLOCK_SPEED, _bus, false); +#elif !HAS_SCREEN + reClockI2C(SCD4X_I2C_CLOCK_SPEED, _bus, true); +#else + LOG_WARN("%s can't be used at this clock speed, with a screen", sensorName); + return AdminMessageHandleResult::NOT_HANDLED; +#endif /* CAN_RECLOCK_I2C */ +#endif /* SCD4X_I2C_CLOCK_SPEED */ + + // TODO: potentially add selftest command? + switch (request->which_payload_variant) { + case meshtastic_AdminMessage_sensor_config_tag: + // Check for ASC-FRC request first + if (!request->sensor_config.has_scd4x_config) { + result = AdminMessageHandleResult::NOT_HANDLED; + break; + } + + if (request->sensor_config.scd4x_config.has_factory_reset) { + LOG_DEBUG("%s: Requested factory reset", sensorName); + this->factoryReset(); + } else { + + if (request->sensor_config.scd4x_config.has_set_asc) { + this->setASC(request->sensor_config.scd4x_config.set_asc); + 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) { + this->performFRC(request->sensor_config.scd4x_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; + } + } else { + LOG_DEBUG("%s: Request for ASC", sensorName); + if (request->sensor_config.scd4x_config.has_set_target_co2_conc) { + LOG_DEBUG("%s: Request has target CO2", sensorName); + // TODO - Remove? see setASCBaseline function + this->setASCBaseline(request->sensor_config.scd4x_config.set_target_co2_conc); + } else { + LOG_DEBUG("%s: Request doesn't have target CO2", sensorName); + } + } + } + + // 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) { + this->setTemperature(request->sensor_config.scd4x_config.set_temperature); + } + + // Check for altitude or pressure offset + if (request->sensor_config.scd4x_config.has_set_altitude) { + this->setAltitude(request->sensor_config.scd4x_config.set_altitude); + } else if (request->sensor_config.scd4x_config.has_set_ambient_pressure) { + this->setAmbientPressure(request->sensor_config.scd4x_config.set_ambient_pressure); + } + + // 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) { + this->setPowerMode(request->sensor_config.scd4x_config.set_power_mode); + } + } + + // Start measurement mode + this->startMeasurement(); + + result = AdminMessageHandleResult::HANDLED; + break; + + default: + result = AdminMessageHandleResult::NOT_HANDLED; + } + +#if defined(SCD4X_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif + + return result; +} + +#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SCD4XSensor.h b/src/modules/Telemetry/Sensor/SCD4XSensor.h new file mode 100644 index 00000000000..1ed86a183b9 --- /dev/null +++ b/src/modules/Telemetry/Sensor/SCD4XSensor.h @@ -0,0 +1,63 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "RTC.h" +#include "TelemetrySensor.h" +#include + +// Max speed 400kHz +#define SCD4X_I2C_CLOCK_SPEED 100000 +#define SCD4X_WARMUP_MS 5000 + +class SCD4XSensor : public TelemetrySensor +{ + private: + SensirionI2cScd4x scd4x; + TwoWire *_bus{}; + uint8_t _address{}; + + bool performFRC(uint32_t targetCO2); + bool setASCBaseline(uint32_t targetCO2); + bool getASC(uint16_t &ascEnabled); + bool setASC(bool ascEnabled); + bool setTemperature(float tempReference); + bool getAltitude(uint16_t &altitude); + bool setAltitude(uint32_t altitude); + bool getAmbientPressure(uint32_t &ambientPressure); + bool setAmbientPressure(uint32_t ambientPressure); + bool factoryReset(); + bool setPowerMode(bool _lowPower); + bool startMeasurement(); + bool stopMeasurement(); + + uint16_t ascActive = 1; + // low power measurement mode (on sensirion side). Disables sleep mode + // Improvement and testing needed for timings + bool lowPower = true; + uint32_t co2MeasureStarted = 0; + + public: + SCD4XSensor(); + virtual bool getMetrics(meshtastic_Telemetry *measurement) override; + virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; + + enum SCD4XState { SCD4X_OFF, SCD4X_IDLE, SCD4X_MEASUREMENT }; + SCD4XState state = SCD4X_OFF; + SCD4xSensorVariant sensorVariant{}; + + virtual bool isActive() override; + + virtual void sleep() override; // Stops measurement (measurement -> idle) + virtual uint32_t wakeUp() override; // Starts measurement (idle -> measurement) + bool powerDown(); // Powers down sensor (idle -> power-off) + bool powerUp(); // Powers the sensor (power-off -> idle) + virtual bool canSleep() override; + virtual int32_t wakeUpTimeMs() override; + virtual int32_t pendingForReadyMs() override; + AdminMessageHandleResult handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) override; +}; + +#endif \ No newline at end of file diff --git a/src/serialization/MeshPacketSerializer.cpp b/src/serialization/MeshPacketSerializer.cpp index 92e70036811..042bc376396 100644 --- a/src/serialization/MeshPacketSerializer.cpp +++ b/src/serialization/MeshPacketSerializer.cpp @@ -149,18 +149,15 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp, if (decoded->variant.air_quality_metrics.has_pm100_standard) { msgPayload["pm100"] = new JSONValue((unsigned int)decoded->variant.air_quality_metrics.pm100_standard); } - // if (decoded->variant.air_quality_metrics.has_pm10_environmental) { - // msgPayload["pm10_e"] = - // new JSONValue((unsigned int)decoded->variant.air_quality_metrics.pm10_environmental); - // } - // if (decoded->variant.air_quality_metrics.has_pm25_environmental) { - // msgPayload["pm25_e"] = - // new JSONValue((unsigned int)decoded->variant.air_quality_metrics.pm25_environmental); - // } - // if (decoded->variant.air_quality_metrics.has_pm100_environmental) { - // msgPayload["pm100_e"] = - // new JSONValue((unsigned int)decoded->variant.air_quality_metrics.pm100_environmental); - // } + if (decoded->variant.air_quality_metrics.has_co2) { + msgPayload["co2"] = new JSONValue((unsigned int)decoded->variant.air_quality_metrics.co2); + } + if (decoded->variant.air_quality_metrics.has_co2_temperature) { + msgPayload["co2_temperature"] = new JSONValue(decoded->variant.air_quality_metrics.co2_temperature); + } + if (decoded->variant.air_quality_metrics.has_co2_humidity) { + msgPayload["co2_humidity"] = new JSONValue(decoded->variant.air_quality_metrics.co2_humidity); + } } else if (decoded->which_variant == meshtastic_Telemetry_power_metrics_tag) { if (decoded->variant.power_metrics.has_ch1_voltage) { msgPayload["voltage_ch1"] = new JSONValue(decoded->variant.power_metrics.ch1_voltage); diff --git a/src/serialization/MeshPacketSerializer_nRF52.cpp b/src/serialization/MeshPacketSerializer_nRF52.cpp index 80aef0e9477..a0ad4e4b9d7 100644 --- a/src/serialization/MeshPacketSerializer_nRF52.cpp +++ b/src/serialization/MeshPacketSerializer_nRF52.cpp @@ -120,15 +120,15 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp, if (decoded->variant.air_quality_metrics.has_pm100_standard) { jsonObj["payload"]["pm100"] = (unsigned int)decoded->variant.air_quality_metrics.pm100_standard; } - // if (decoded->variant.air_quality_metrics.has_pm10_environmental) { - // jsonObj["payload"]["pm10_e"] = (unsigned int)decoded->variant.air_quality_metrics.pm10_environmental; - // } - // if (decoded->variant.air_quality_metrics.has_pm25_environmental) { - // jsonObj["payload"]["pm25_e"] = (unsigned int)decoded->variant.air_quality_metrics.pm25_environmental; - // } - // if (decoded->variant.air_quality_metrics.has_pm100_environmental) { - // jsonObj["payload"]["pm100_e"] = (unsigned int)decoded->variant.air_quality_metrics.pm100_environmental; - // } + if (decoded->variant.air_quality_metrics.has_co2) { + jsonObj["payload"]["co2"] = (unsigned int)decoded->variant.air_quality_metrics.co2; + } + if (decoded->variant.air_quality_metrics.has_co2_temperature) { + jsonObj["payload"]["co2_temperature"] = decoded->variant.air_quality_metrics.co2_temperature; + } + if (decoded->variant.air_quality_metrics.has_co2_humidity) { + jsonObj["payload"]["co2_humidity"] = decoded->variant.air_quality_metrics.co2_humidity; + } } else if (decoded->which_variant == meshtastic_Telemetry_power_metrics_tag) { if (decoded->variant.power_metrics.has_ch1_voltage) { jsonObj["payload"]["voltage_ch1"] = decoded->variant.power_metrics.ch1_voltage;