Compare commits

...

2 Commits

Author SHA1 Message Date
Ben Meadors 8f18534ebd Protobufs 2026-02-11 06:24:02 -06:00
Ben Meadors fd084abbd9 Add STC31 CO2 sensor support and related configurations 2026-02-11 06:23:38 -06:00
12 changed files with 276 additions and 18 deletions
+4
View File
@@ -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
+1
View File
@@ -234,6 +234,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#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
+2 -1
View File
@@ -90,7 +90,8 @@ class ScanI2C
CHSC6X,
CST226SE,
CW2015,
SEN5X
SEN5X,
STC31
} DeviceType;
// typedef uint8_t DeviceAddress;
+64 -6
View File
@@ -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);
+1
View File
@@ -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
+22 -10
View File
@@ -25,9 +25,14 @@
#if __has_include(<SensirionI2cScd4x.h>)
#include "Sensor/SCD4XSensor.h"
#endif
#if __has_include(<SensirionI2cStc3x.h>)
#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(<SensirionI2cScd4x.h>)
addSensor<SCD4XSensor>(i2cScanner, ScanI2C::DeviceType::SCD4X);
#endif
#if __has_include(<SensirionI2cStc3x.h>)
addSensor<STC31Sensor>(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();
}
@@ -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;
}
@@ -0,0 +1,137 @@
#include "configuration.h"
#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR && __has_include(<SensirionI2cStc3x.h>)
#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
@@ -0,0 +1,26 @@
#include "configuration.h"
#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR && __has_include(<SensirionI2cStc3x.h>)
#include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "TelemetrySensor.h"
#include <SensirionI2cStc3x.h>
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
@@ -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
@@ -16,6 +16,11 @@ class TwoWire;
#define DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS 1000
extern std::pair<uint8_t, TwoWire *> 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: