diff --git a/platformio.ini b/platformio.ini index b7123b865..5a276f8fb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -214,6 +214,8 @@ lib_deps = sensirion/Sensirion Core@0.7.2 # renovate: datasource=custom.pio depName=Sensirion I2C SCD4x packageName=sensirion/library/Sensirion I2C SCD4x sensirion/Sensirion I2C SCD4x@1.1.0 + # renovate: datasource=custom.pio depName=Sensirion I2C STC3x packageName=sensirion/library/Sensirion I2C STC3x + sensirion/Sensirion I2C STC3x@1.0.1 ; Same as environmental_extra but without BSEC (saves ~3.5KB DRAM for original ESP32 targets) [environmental_extra_no_bsec] @@ -242,3 +244,5 @@ lib_deps = sensirion/Sensirion Core@0.7.2 # renovate: datasource=custom.pio depName=Sensirion I2C SCD4x packageName=sensirion/library/Sensirion I2C SCD4x sensirion/Sensirion I2C SCD4x@1.1.0 + # renovate: datasource=custom.pio depName=Sensirion I2C STC3x packageName=sensirion/library/Sensirion I2C STC3x + sensirion/Sensirion I2C STC3x@1.0.1 diff --git a/src/configuration.h b/src/configuration.h index 5d8d764c7..7b6fad684 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -234,6 +234,7 @@ along with this program. If not, see . #define MAX30102_ADDR 0x57 #define SCD4X_ADDR 0x62 #define CW2015_ADDR 0x62 +#define STC31_ADDR 0x2c // RAK12008 uses 0x2C address #define MLX90614_ADDR_DEF 0x5A #define CGRADSENS_ADDR 0x66 #define LTR390UV_ADDR 0x53 diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index 528159661..7ba8ea30e 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -90,7 +90,8 @@ class ScanI2C CHSC6X, CST226SE, CW2015, - SEN5X + SEN5X, + STC31 } DeviceType; // typedef uint8_t DeviceAddress; diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index e5e69dca7..538cfb357 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -154,6 +154,42 @@ String readSEN5xProductName(TwoWire *i2cBus, uint8_t address) return String(productName); } +/// for STC31 detection - probe by reading product identifier +/// Uses two-step sequence: prepareProductIdentifier (0x367c) then readProductIdentifier (0xe102) +bool probeSTC31(TwoWire *i2cBus, uint8_t address) +{ + // Step 1: Send prepare product identifier command (0x367c) + uint8_t prepCmd[] = {0x36, 0x7c}; + i2cBus->beginTransmission(address); + i2cBus->write(prepCmd, 2); + if (i2cBus->endTransmission() != 0) + return false; + delay(1); + + // Step 2: Send read product identifier command (0xe102) + uint8_t readCmd[] = {0xe1, 0x02}; + i2cBus->beginTransmission(address); + i2cBus->write(readCmd, 2); + if (i2cBus->endTransmission() != 0) + return false; + delay(10); + + // Read 6 bytes: product number (4 bytes with CRC) + more data + // Product number is 32-bit: STC31 = 0x08010301 + if (i2cBus->requestFrom(address, (uint8_t)6) != 6) + return false; + + uint8_t response[6] = {0}; + for (int i = 0; i < 6 && i2cBus->available(); ++i) { + response[i] = i2cBus->read(); + } + + uint32_t productId = + ((uint32_t)response[0] << 24) | ((uint32_t)response[1] << 16) | ((uint32_t)response[3] << 8) | response[4]; + + return productId == 0x08010301; +} + #define SCAN_SIMPLE_CASE(ADDR, T, ...) \ case ADDR: \ logFoundDevice(__VA_ARGS__); \ @@ -532,13 +568,23 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) } break; case TSL25911_ADDR: - registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xA0 | 0x12), 1); - if (registerValue == 0x50) { - type = TSL2591; - logFoundDevice("TSL25911", (uint8_t)addr.address); + // TSL25911_ADDR (0x29) is shared by TSL2591, TSL2561, and STC31 + // Probe for STC31 first (Sensirion CO2 sensor) + LOG_DEBUG("Probing address 0x29 for STC31..."); + if (probeSTC31(i2cBus, addr.address)) { + type = STC31; + LOG_DEBUG("STC31 probe successful!"); + logFoundDevice("STC31", (uint8_t)addr.address); } else { - type = TSL2561; - logFoundDevice("TSL2561", (uint8_t)addr.address); + LOG_DEBUG("STC31 probe failed, checking for TSL sensors..."); + registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xA0 | 0x12), 1); + if (registerValue == 0x50) { + type = TSL2591; + logFoundDevice("TSL25911", (uint8_t)addr.address); + } else { + type = TSL2561; + logFoundDevice("TSL2561", (uint8_t)addr.address); + } } break; @@ -548,6 +594,18 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) SCAN_SIMPLE_CASE(DFROBOT_RAIN_ADDR, DFROBOT_RAIN, "DFRobot Rain Gauge", (uint8_t)addr.address); SCAN_SIMPLE_CASE(LTR390UV_ADDR, LTR390UV, "LTR390UV", (uint8_t)addr.address); SCAN_SIMPLE_CASE(PCT2075_ADDR, PCT2075, "PCT2075", (uint8_t)addr.address); + case STC31_ADDR: + // RAK12008 uses address 0x2C for STC31 + LOG_DEBUG("Probing address 0x2c for STC31..."); + if (probeSTC31(i2cBus, addr.address)) { + type = STC31; + LOG_DEBUG("STC31 probe successful at 0x2c!"); + logFoundDevice("STC31", (uint8_t)addr.address); + } else { + LOG_DEBUG("STC31 probe failed at 0x2c"); + LOG_INFO("Device found at address 0x%x was not able to be enumerated", (uint8_t)addr.address); + } + break; case CST328_ADDR: // Do we have the CST328 or the CST226SE registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xAB), 1); diff --git a/src/main.cpp b/src/main.cpp index 44885813e..383c884b6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -680,6 +680,7 @@ void setup() scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::ICM20948, meshtastic_TelemetrySensorType_ICM20948); scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::MAX30102, meshtastic_TelemetrySensorType_MAX30102); scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::SCD4X, meshtastic_TelemetrySensorType_SCD4X); + scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::STC31, meshtastic_TelemetrySensorType_STC31); #endif #ifdef HAS_SDCARD diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp index aac398eb9..d270bc6bd 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.cpp +++ b/src/modules/Telemetry/AirQualityTelemetry.cpp @@ -25,9 +25,14 @@ #if __has_include() #include "Sensor/SCD4XSensor.h" #endif +#if __has_include() +#include "Sensor/STC31Sensor.h" +#endif void AirQualityTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) { + LOG_DEBUG("AirQualityTelemetryModule::i2cScanFinished called, air_quality_enabled=%d", + moduleConfig.telemetry.air_quality_enabled); if (!moduleConfig.telemetry.air_quality_enabled && !AIR_QUALITY_TELEMETRY_MODULE_ENABLE) { return; } @@ -50,6 +55,9 @@ void AirQualityTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::SCD4X); #endif +#if __has_include() + addSensor(i2cScanner, ScanI2C::DeviceType::STC31); +#endif } int32_t AirQualityTelemetryModule::runOnce() @@ -92,6 +100,10 @@ int32_t AirQualityTelemetryModule::runOnce() return disable(); } + bool isSensorRole = config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR; + // SENSOR role bypasses airtime restrictions - telemetry is its primary purpose + bool airTimeAllows = isSensorRole || (airTime->isTxAllowedChannelUtil(true) && airTime->isTxAllowedAirUtil()); + // Wake up the sensors that need it LOG_INFO("Waking up sensors..."); for (TelemetrySensor *sensor : sensors) { @@ -102,8 +114,7 @@ int32_t AirQualityTelemetryModule::runOnce() Default::getConfiguredOrDefaultMsScaled( moduleConfig.telemetry.air_quality_interval, default_telemetry_broadcast_interval_secs, numOnlineNodes))) && - airTime->isTxAllowedChannelUtil(config.device.role != meshtastic_Config_DeviceConfig_Role_SENSOR) && - airTime->isTxAllowedAirUtil()) { + airTimeAllows) { if (!sensor->isActive()) { LOG_DEBUG("Waking up: %s", sensor->sensorName); return sensor->wakeUp(); @@ -117,18 +128,19 @@ int32_t AirQualityTelemetryModule::runOnce() } } - if (((lastSentToMesh == 0) || - !Throttle::isWithinTimespanMs(lastSentToMesh, Default::getConfiguredOrDefaultMsScaled( - moduleConfig.telemetry.air_quality_interval, - default_telemetry_broadcast_interval_secs, numOnlineNodes))) && - airTime->isTxAllowedChannelUtil(config.device.role != meshtastic_Config_DeviceConfig_Role_SENSOR) && - airTime->isTxAllowedAirUtil()) { + bool timeToSend = (lastSentToMesh == 0) || + !Throttle::isWithinTimespanMs( + lastSentToMesh, + Default::getConfiguredOrDefaultMsScaled(moduleConfig.telemetry.air_quality_interval, + default_telemetry_broadcast_interval_secs, numOnlineNodes)); + + if (timeToSend && airTimeAllows) { sendTelemetry(); lastSentToMesh = millis(); } else if (((lastSentToPhone == 0) || !Throttle::isWithinTimespanMs(lastSentToPhone, sendToPhoneIntervalMs)) && - (service->isToPhoneQueueEmpty())) { + (isSensorRole || service->isToPhoneQueueEmpty())) { // Just send to phone when it's not our time to send to mesh yet - // Only send while queue is empty (phone assumed connected) + // SENSOR role always sends; others only send while queue is empty (phone assumed connected) sendTelemetry(NODENUM_BROADCAST, true); lastSentToPhone = millis(); } diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index 140c2c17e..6d3b1efcf 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -26,6 +26,7 @@ // Sensors #include "Sensor/CGRadSensSensor.h" #include "Sensor/RCWL9620Sensor.h" +#include "Sensor/TelemetrySensor.h" #include "Sensor/nullSensor.h" namespace graphics @@ -571,6 +572,13 @@ bool EnvironmentTelemetryModule::getEnvironmentTelemetry(meshtastic_Telemetry *m valid = valid || get_metrics; hasSensor = true; #endif + + // Update shared humidity for cross-sensor compensation (e.g., STC31 CO2 sensor) + if (m->variant.environment_metrics.has_relative_humidity) { + lastEnvironmentHumidity = m->variant.environment_metrics.relative_humidity; + hasValidHumidity = true; + } + return valid && hasSensor; } diff --git a/src/modules/Telemetry/Sensor/STC31Sensor.cpp b/src/modules/Telemetry/Sensor/STC31Sensor.cpp new file mode 100644 index 000000000..f9a660194 --- /dev/null +++ b/src/modules/Telemetry/Sensor/STC31Sensor.cpp @@ -0,0 +1,137 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "STC31Sensor.h" + +#define STC31_NO_ERROR 0 + +// Binary gas configurations from STC31 datasheet +// See: setBinaryGas() - section 3.3.2 in the datasheet +#define STC31_BINARY_GAS_CO2_N2_100 0x0000 // CO2 in N2, 0-100 vol% +#define STC31_BINARY_GAS_CO2_AIR_100 0x0001 // CO2 in air, 0-100 vol% +#define STC31_BINARY_GAS_CO2_N2_25 0x0002 // CO2 in N2, 0-25 vol% +#define STC31_BINARY_GAS_CO2_AIR_25 0x0003 // CO2 in air, 0-25 vol% + +STC31Sensor::STC31Sensor() : TelemetrySensor(meshtastic_TelemetrySensorType_STC31, "STC31") {} + +bool STC31Sensor::configureBinaryGas() +{ + // STC31 requires binary gas mode to be set before measurements + // It resets to default mode (no gas selected) after power cycle or reset + LOG_DEBUG("%s: Configuring binary gas mode (CO2 in air, 0-25%%)", sensorName); + int16_t error = stc3x.setBinaryGas(STC31_BINARY_GAS_CO2_AIR_25); + if (error != STC31_NO_ERROR) { + LOG_ERROR("%s: Failed to set binary gas mode, error: %d", sensorName, error); + return false; + } + LOG_DEBUG("%s: Binary gas mode configured successfully", sensorName); + binaryGasConfigured = true; + return true; +} + +bool STC31Sensor::setHumidityCompensation() +{ + // Use actual humidity from environment sensor if available, otherwise default to 50% + float humidity = hasValidHumidity ? lastEnvironmentHumidity : 50.0f; + + LOG_DEBUG("%s: Setting humidity compensation to %.1f%%", sensorName, humidity); + int16_t error = stc3x.setRelativeHumidity(humidity); + if (error != STC31_NO_ERROR) { + LOG_WARN("%s: Failed to set humidity compensation (%.1f%%), error: %d", sensorName, humidity, error); + return false; + } + return true; +} + +bool STC31Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) +{ + LOG_INFO("Init sensor: %s at address 0x%02x", sensorName, dev->address.address); + + _bus = bus; + _address = dev->address.address; + + stc3x.begin(*_bus, _address); + + // Allow sensor to fully stabilize after power-on + delay(100); + + // Run self-test to verify sensor is working + STC3xTestResultT selfTestResult; + int16_t testError = stc3x.selfTest(selfTestResult); + if (testError != STC31_NO_ERROR) { + LOG_WARN("%s: Self-test command failed with error: %d", sensorName, testError); + } else if (selfTestResult.value != 0) { + LOG_WARN("%s: Self-test reported error: 0x%04x", sensorName, selfTestResult.value); + } else { + LOG_DEBUG("%s: Self-test passed", sensorName); + } + + // Wait after self-test before configuring + delay(50); + + if (!configureBinaryGas()) + return false; + + setHumidityCompensation(); // Non-fatal if this fails + + status = 1; + initI2CSensor(); + + return true; +} + +bool STC31Sensor::getMetrics(meshtastic_Telemetry *measurement) +{ + float gasConcentration = 0; + float temperature = 0; + + // Binary gas mode is configured once during initDevice() and persists + // Humidity compensation can be updated for each measurement + setHumidityCompensation(); // Non-fatal if this fails + + // Small delay for humidity compensation to take effect + delay(10); + + // Retry measurement up to 3 times with increasing delays + int16_t error = STC31_NO_ERROR; + for (int attempt = 0; attempt < 3; attempt++) { + error = stc3x.measureGasConcentration(gasConcentration, temperature); + if (error == STC31_NO_ERROR) + break; + LOG_WARN("%s: Measurement attempt %d failed (error %d), retrying...", sensorName, attempt + 1, error); + + // If we get NotEnoughDataError (0x00F in error), the sensor may have lost its config + // Try re-configuring binary gas mode + if ((error & 0x00F) == 0x00F && attempt == 1) { + LOG_WARN("%s: Sensor may have lost configuration, re-configuring binary gas mode", sensorName); + configureBinaryGas(); + delay(100); + } else { + delay(100 * (attempt + 1)); + } + } + + if (error != STC31_NO_ERROR) { + LOG_ERROR("%s: Error reading measurement after retries: %d", sensorName, error); + // Mark that we need to re-configure on next attempt + binaryGasConfigured = false; + return false; + } + + // Convert percentage to ppm: 1% = 10000 ppm + uint32_t co2_ppm = (uint32_t)(gasConcentration * 10000.0f); + + LOG_DEBUG("%s readings: %.2f%% CO2 (=%u ppm), %.2f degC", sensorName, gasConcentration, co2_ppm, temperature); + + measurement->variant.air_quality_metrics.has_co2 = true; + measurement->variant.air_quality_metrics.co2 = co2_ppm; + + measurement->variant.air_quality_metrics.has_co2_temperature = true; + measurement->variant.air_quality_metrics.co2_temperature = temperature; + + return true; +} + +#endif diff --git a/src/modules/Telemetry/Sensor/STC31Sensor.h b/src/modules/Telemetry/Sensor/STC31Sensor.h new file mode 100644 index 000000000..63eb58bb7 --- /dev/null +++ b/src/modules/Telemetry/Sensor/STC31Sensor.h @@ -0,0 +1,26 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "TelemetrySensor.h" +#include + +class STC31Sensor : public TelemetrySensor +{ + private: + SensirionI2cStc3x stc3x; + TwoWire *_bus{}; + uint8_t _address{}; + bool binaryGasConfigured = false; + + bool configureBinaryGas(); + bool setHumidityCompensation(); + + public: + STC31Sensor(); + virtual bool getMetrics(meshtastic_Telemetry *measurement) override; + virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; +}; + +#endif diff --git a/src/modules/Telemetry/Sensor/TelemetrySensor.cpp b/src/modules/Telemetry/Sensor/TelemetrySensor.cpp index f854cb5fe..2717f2e3d 100644 --- a/src/modules/Telemetry/Sensor/TelemetrySensor.cpp +++ b/src/modules/Telemetry/Sensor/TelemetrySensor.cpp @@ -7,4 +7,9 @@ #include "TelemetrySensor.h" #include "main.h" +// Shared humidity value for cross-sensor compensation +// Default to 50% if no humidity sensor is available +float lastEnvironmentHumidity = 50.0f; +bool hasValidHumidity = false; + #endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/TelemetrySensor.h b/src/modules/Telemetry/Sensor/TelemetrySensor.h index 6c53bbd72..366768097 100644 --- a/src/modules/Telemetry/Sensor/TelemetrySensor.h +++ b/src/modules/Telemetry/Sensor/TelemetrySensor.h @@ -16,6 +16,11 @@ class TwoWire; #define DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS 1000 extern std::pair nodeTelemetrySensorsMap[_meshtastic_TelemetrySensorType_MAX + 1]; +// Shared humidity value for cross-sensor compensation (e.g., STC31 CO2 sensor) +// Updated by environment sensors that measure humidity +extern float lastEnvironmentHumidity; +extern bool hasValidHumidity; + class TelemetrySensor { protected: