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: