This commit is contained in:
oscgonfer 2025-09-01 09:46:53 -04:00 committed by GitHub
commit eb9abaf0fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1362 additions and 133 deletions

View File

@ -141,8 +141,6 @@ lib_deps =
adafruit/Adafruit INA260 Library@1.5.3 adafruit/Adafruit INA260 Library@1.5.3
# renovate: datasource=custom.pio depName=Adafruit INA219 packageName=adafruit/library/Adafruit INA219 # renovate: datasource=custom.pio depName=Adafruit INA219 packageName=adafruit/library/Adafruit INA219
adafruit/Adafruit INA219@1.2.3 adafruit/Adafruit INA219@1.2.3
# renovate: datasource=custom.pio depName=Adafruit PM25 AQI Sensor packageName=adafruit/library/Adafruit PM25 AQI Sensor
adafruit/Adafruit PM25 AQI Sensor@2.0.0
# renovate: datasource=custom.pio depName=Adafruit MPU6050 packageName=adafruit/library/Adafruit MPU6050 # renovate: datasource=custom.pio depName=Adafruit MPU6050 packageName=adafruit/library/Adafruit MPU6050
adafruit/Adafruit MPU6050@2.2.6 adafruit/Adafruit MPU6050@2.2.6
# renovate: datasource=custom.pio depName=Adafruit LIS3DH packageName=adafruit/library/Adafruit LIS3DH # renovate: datasource=custom.pio depName=Adafruit LIS3DH packageName=adafruit/library/Adafruit LIS3DH

View File

@ -109,6 +109,75 @@ uint16_t ScanI2CTwoWire::getRegisterValue(const ScanI2CTwoWire::RegisterLocation
return value; return value;
} }
bool ScanI2CTwoWire::setClockSpeed(I2CPort port, uint32_t speed) {
DeviceAddress addr(port, 0x00);
TwoWire *i2cBus;
#if WIRE_INTERFACES_COUNT == 2
if (port == I2CPort::WIRE1) {
i2cBus = &Wire1;
} else {
#endif
i2cBus = &Wire;
#if WIRE_INTERFACES_COUNT == 2
}
#endif
return i2cBus->setClock(speed);
}
uint32_t ScanI2CTwoWire::getClockSpeed(I2CPort port) {
DeviceAddress addr(port, 0x00);
TwoWire *i2cBus;
#if WIRE_INTERFACES_COUNT == 2
if (port == I2CPort::WIRE1) {
i2cBus = &Wire1;
} else {
#endif
i2cBus = &Wire;
#if WIRE_INTERFACES_COUNT == 2
}
#endif
return i2cBus->getClock();
}
/// for SEN5X detection
String readSEN5xProductName(TwoWire* i2cBus, uint8_t address) {
uint8_t cmd[] = { 0xD0, 0x14 };
uint8_t response[48] = {0};
i2cBus->beginTransmission(address);
i2cBus->write(cmd, 2);
if (i2cBus->endTransmission() != 0) return "";
delay(20);
if (i2cBus->requestFrom(address, (uint8_t)48) != 48) return "";
for (int i = 0; i < 48 && i2cBus->available(); ++i) {
response[i] = i2cBus->read();
}
char productName[33] = {0};
int j = 0;
for (int i = 0; i < 48 && j < 32; i += 3) {
if (response[i] >= 32 && response[i] <= 126)
productName[j++] = response[i];
else
break;
if (response[i + 1] >= 32 && response[i + 1] <= 126)
productName[j++] = response[i + 1];
else
break;
}
return String(productName);
}
#define SCAN_SIMPLE_CASE(ADDR, T, ...) \ #define SCAN_SIMPLE_CASE(ADDR, T, ...) \
case ADDR: \ case ADDR: \
logFoundDevice(__VA_ARGS__); \ logFoundDevice(__VA_ARGS__); \

View File

@ -29,6 +29,9 @@ class ScanI2CTwoWire : public ScanI2C
size_t countDevices() const override; size_t countDevices() const override;
bool setClockSpeed(ScanI2C::I2CPort, uint32_t);
uint32_t getClockSpeed(ScanI2C::I2CPort);
protected: protected:
FoundDevice firstOfOrNONE(size_t, DeviceType[]) const override; FoundDevice firstOfOrNONE(size_t, DeviceType[]) const override;

View File

@ -531,6 +531,7 @@ void setup()
Wire.setSCL(I2C_SCL); Wire.setSCL(I2C_SCL);
Wire.begin(); Wire.begin();
#elif defined(I2C_SDA) && !defined(ARCH_RP2040) #elif defined(I2C_SDA) && !defined(ARCH_RP2040)
LOG_INFO("Starting Bus with (SDA) %d and (SCL) %d: ", I2C_SDA, I2C_SCL);
Wire.begin(I2C_SDA, I2C_SCL); Wire.begin(I2C_SDA, I2C_SCL);
#elif defined(ARCH_PORTDUINO) #elif defined(ARCH_PORTDUINO)
if (settingsStrings[i2cdev] != "") { if (settingsStrings[i2cdev] != "") {
@ -574,6 +575,39 @@ void setup()
LOG_INFO("Scan for i2c devices"); LOG_INFO("Scan for i2c devices");
#endif #endif
// Scan I2C port at desired speed
#ifdef SCAN_I2C_CLOCK_SPEED
uint32_t currentClock;
currentClock = i2cScanner->getClockSpeed(ScanI2C::I2CPort::WIRE);
LOG_INFO("Clock speed: %uHz on WIRE", currentClock);
LOG_DEBUG("Setting Wire with defined clock speed, %uHz...", SCAN_I2C_CLOCK_SPEED);
if(!i2cScanner->setClockSpeed(ScanI2C::I2CPort::WIRE, SCAN_I2C_CLOCK_SPEED)) {
LOG_ERROR("Unable to set clock speed on WIRE");
} else {
currentClock = i2cScanner->getClockSpeed(ScanI2C::I2CPort::WIRE);
LOG_INFO("Set clock speed: %uHz on WIRE", currentClock);
}
// TODO Check if necessary
// LOG_DEBUG("Starting Wire with defined clock speed, %d...", SCAN_I2C_CLOCK_SPEED);
// if(!i2cScanner->setClockSpeed(ScanI2C::I2CPort::WIRE1, SCAN_I2C_CLOCK_SPEED)) {
// LOG_ERROR("Unable to set clock speed on WIRE1");
// } else {
// LOG_INFO("Set clock speed: %d on WIRE1", SCAN_I2C_CLOCK_SPEED);
// }
// Restore clock speed
if (currentClock != SCAN_I2C_CLOCK_SPEED) {
if(!i2cScanner->setClockSpeed(ScanI2C::I2CPort::WIRE, currentClock)) {
LOG_ERROR("Unable to restore clock speed on WIRE");
} else {
currentClock = i2cScanner->getClockSpeed(ScanI2C::I2CPort::WIRE);
LOG_INFO("Set clock speed restored to: %uHz on WIRE", currentClock);
}
}
#endif
#if defined(I2C_SDA1) || (defined(NRF52840_XXAA) && (WIRE_INTERFACES_COUNT == 2)) #if defined(I2C_SDA1) || (defined(NRF52840_XXAA) && (WIRE_INTERFACES_COUNT == 2))
i2cScanner->scanPort(ScanI2C::I2CPort::WIRE1); i2cScanner->scanPort(ScanI2C::I2CPort::WIRE1);
#endif #endif

View File

@ -231,11 +231,7 @@ void setupModules()
// TODO: How to improve this? // TODO: How to improve this?
#if HAS_SENSOR && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR #if HAS_SENSOR && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
new EnvironmentTelemetryModule(); new EnvironmentTelemetryModule();
#if __has_include("Adafruit_PM25AQI.h") new AirQualityTelemetryModule();
if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_PMSA003I].first > 0) {
new AirQualityTelemetryModule();
}
#endif
#if !MESHTASTIC_EXCLUDE_HEALTH_TELEMETRY #if !MESHTASTIC_EXCLUDE_HEALTH_TELEMETRY
if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_MAX30102].first > 0 || if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_MAX30102].first > 0 ||
nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_MLX90614].first > 0) { nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_MLX90614].first > 0) {

View File

@ -1,36 +1,61 @@
#include "configuration.h" #include "configuration.h"
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include("Adafruit_PM25AQI.h") #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
#include "../mesh/generated/meshtastic/telemetry.pb.h" #include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "AirQualityTelemetry.h"
#include "Default.h" #include "Default.h"
#include "AirQualityTelemetry.h"
#include "MeshService.h" #include "MeshService.h"
#include "NodeDB.h" #include "NodeDB.h"
#include "PowerFSM.h" #include "PowerFSM.h"
#include "RTC.h" #include "RTC.h"
#include "Router.h" #include "Router.h"
#include "detect/ScanI2CTwoWire.h" #include "UnitConversions.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/images.h"
#include "main.h" #include "main.h"
#include "sleep.h"
#include <Throttle.h> #include <Throttle.h>
// Sensor includes
#include "Sensor/PMSA003ISensor.h"
#ifndef PMSA003I_WARMUP_MS // Sensors
// from the PMSA003I datasheet: PMSA003ISensor pmsa003iSensor;
// "Stable data should be got at least 30 seconds after the sensor wakeup
// from the sleep mode because of the fans performance."
#define PMSA003I_WARMUP_MS 30000 #if __has_include(<SensirionI2cScd4x.h>)
#include "Sensor/SCD4XSensor.h"
SCD4XSensor scd4xSensor;
#else
NullSensor scd4xSensor;
#endif #endif
#include "graphics/ScreenFonts.h"
int32_t AirQualityTelemetryModule::runOnce() int32_t AirQualityTelemetryModule::runOnce()
{ {
if (sleepOnNextExecution == true) {
sleepOnNextExecution = false;
uint32_t nightyNightMs = Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.air_quality_interval,
default_telemetry_broadcast_interval_secs);
LOG_DEBUG("Sleeping for %ims, then awaking to send metrics again.", nightyNightMs);
doDeepSleep(nightyNightMs, true, false);
}
uint32_t result = UINT32_MAX;
/* /*
Uncomment the preferences below if you want to use the module Uncomment the preferences below if you want to use the module
without having to configure it from the PythonAPI or WebUI. without having to configure it from the PythonAPI or WebUI.
*/ */
// moduleConfig.telemetry.air_quality_enabled = 1; // moduleConfig.telemetry.air_quality_enabled = 1;
// TODO there is no config in module_config.proto for air_quality_screen_enabled. Reusing environment one, although it should have its own
// moduleConfig.telemetry.environment_screen_enabled = 1;
// moduleConfig.telemetry.air_quality_interval = 15;
if (!(moduleConfig.telemetry.air_quality_enabled)) { if (!(moduleConfig.telemetry.air_quality_enabled || moduleConfig.telemetry.environment_screen_enabled ||
AIR_QUALITY_TELEMETRY_MODULE_ENABLE)) {
// If this module is not enabled, and the user doesn't want the display screen don't waste any OSThread time on it // If this module is not enabled, and the user doesn't want the display screen don't waste any OSThread time on it
return disable(); return disable();
} }
@ -42,79 +67,147 @@ int32_t AirQualityTelemetryModule::runOnce()
if (moduleConfig.telemetry.air_quality_enabled) { if (moduleConfig.telemetry.air_quality_enabled) {
LOG_INFO("Air quality Telemetry: init"); LOG_INFO("Air quality Telemetry: init");
#ifdef PMSA003I_ENABLE_PIN if (pmsa003iSensor.hasSensor())
// put the sensor to sleep on startup result = pmsa003iSensor.runOnce();
pinMode(PMSA003I_ENABLE_PIN, OUTPUT);
digitalWrite(PMSA003I_ENABLE_PIN, LOW);
#endif /* PMSA003I_ENABLE_PIN */
if (!aqi.begin_I2C()) { if (scd4xSensor.hasSensor())
#ifndef I2C_NO_RESCAN result = scd4xSensor.runOnce();
LOG_WARN("Could not establish i2c connection to AQI sensor. Rescan");
// rescan for late arriving sensors. AQI Module starts about 10 seconds into the boot so this is plenty.
uint8_t i2caddr_scan[] = {PMSA0031_ADDR};
uint8_t i2caddr_asize = 1;
auto i2cScanner = std::unique_ptr<ScanI2CTwoWire>(new ScanI2CTwoWire());
#if defined(I2C_SDA1)
i2cScanner->scanPort(ScanI2C::I2CPort::WIRE1, i2caddr_scan, i2caddr_asize);
#endif
i2cScanner->scanPort(ScanI2C::I2CPort::WIRE, i2caddr_scan, i2caddr_asize);
auto found = i2cScanner->find(ScanI2C::DeviceType::PMSA0031);
if (found.type != ScanI2C::DeviceType::NONE) {
nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_PMSA003I].first = found.address.address;
nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_PMSA003I].second =
i2cScanner->fetchI2CBus(found.address);
return setStartDelay();
}
#endif
return disable();
}
return setStartDelay();
} }
return disable();
// it's possible to have this module enabled, only for displaying values on the screen.
// therefore, we should only enable the sensor loop if measurement is also enabled
return result == UINT32_MAX ? disable() : setStartDelay();
} else { } else {
// if we somehow got to a second run of this module with measurement disabled, then just wait forever // if we somehow got to a second run of this module with measurement disabled, then just wait forever
if (!moduleConfig.telemetry.air_quality_enabled) if (!moduleConfig.telemetry.air_quality_enabled && !AIR_QUALITY_TELEMETRY_MODULE_ENABLE) {
return disable();
switch (state) {
#ifdef PMSA003I_ENABLE_PIN
case State::IDLE:
// sensor is in standby; fire it up and sleep
LOG_DEBUG("runOnce(): state = idle");
digitalWrite(PMSA003I_ENABLE_PIN, HIGH);
state = State::ACTIVE;
return PMSA003I_WARMUP_MS;
#endif /* PMSA003I_ENABLE_PIN */
case State::ACTIVE:
// sensor is already warmed up; grab telemetry and send it
LOG_DEBUG("runOnce(): state = active");
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()) {
sendTelemetry();
lastSentToMesh = millis();
} else if (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)
sendTelemetry(NODENUM_BROADCAST, true);
}
#ifdef PMSA003I_ENABLE_PIN
// put sensor back to sleep
digitalWrite(PMSA003I_ENABLE_PIN, LOW);
state = State::IDLE;
#endif /* PMSA003I_ENABLE_PIN */
return sendToPhoneIntervalMs;
default:
return disable(); return disable();
} }
// Wake up the sensors that need it
#ifdef PMSA003I_ENABLE_PIN
if (pmsa003iSensor.hasSensor() && !pmsa003iSensor.isActive())
return pmsa003iSensor.wakeUp();
#endif /* PMSA003I_ENABLE_PIN */
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()) {
sendTelemetry();
lastSentToMesh = millis();
} else if (((lastSentToPhone == 0) || !Throttle::isWithinTimespanMs(lastSentToPhone, sendToPhoneIntervalMs)) &&
(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)
sendTelemetry(NODENUM_BROADCAST, true);
lastSentToPhone = millis();
}
#ifdef PMSA003I_ENABLE_PIN
pmsa003iSensor.sleep();
#endif /* PMSA003I_ENABLE_PIN */
}
return min(sendToPhoneIntervalMs, result);
}
bool AirQualityTelemetryModule::wantUIFrame()
{
return moduleConfig.telemetry.environment_screen_enabled;
}
void AirQualityTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
// === Setup display ===
display->clear();
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_LEFT);
int line = 1;
// === Set Title
const char *titleStr = (graphics::isHighResolution) ? "Environment" : "Env.";
// === Header ===
graphics::drawCommonHeader(display, x, y, titleStr);
// === Row spacing setup ===
const int rowHeight = FONT_HEIGHT_SMALL - 4;
int currentY = graphics::getTextPositions(display)[line++];
// === Show "No Telemetry" if no data available ===
if (!lastMeasurementPacket) {
display->drawString(x, currentY, "No Telemetry");
return;
}
// Decode the telemetry message from the latest received packet
const meshtastic_Data &p = lastMeasurementPacket->decoded;
meshtastic_Telemetry telemetry;
if (!pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_Telemetry_msg, &telemetry)) {
display->drawString(x, currentY, "No Telemetry");
return;
}
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 || m.has_pm10_environmental || m.has_pm25_environmental ||
m.has_pm100_environmental || m.has_co2;
if (!hasAny) {
display->drawString(x, currentY, "No Telemetry");
return;
}
// === First line: Show sender name + time since received (left), and first metric (right) ===
const char *sender = getSenderShortName(*lastMeasurementPacket);
uint32_t agoSecs = service->GetTimeSinceMeshPacket(lastMeasurementPacket);
String agoStr = (agoSecs > 864000) ? "?"
: (agoSecs > 3600) ? String(agoSecs / 3600) + "h"
: (agoSecs > 60) ? String(agoSecs / 60) + "m"
: String(agoSecs) + "s";
String leftStr = String(sender) + " (" + agoStr + ")";
display->drawString(x, currentY, leftStr); // Left side: who and when
// === Collect sensor readings as label strings (no icons) ===
std::vector<String> entries;
if (m.has_pm10_standard)
entries.push_back("PM1.0: " + String(m.pm10_standard) + "ug/m3");
if (m.has_pm25_standard)
entries.push_back("PM2.5: " + String(m.pm25_standard) + "ug/m3");
if (m.has_pm100_standard)
entries.push_back("PM10.0: " + 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()) {
String valueStr = entries.front();
int rightX = SCREEN_WIDTH - display->getStringWidth(valueStr);
display->drawString(rightX, currentY, valueStr);
entries.erase(entries.begin()); // Remove from queue
}
// === Advance to next line for remaining telemetry entries ===
currentY += rowHeight;
// === Draw remaining entries in 2-column format (left and right) ===
for (size_t i = 0; i < entries.size(); i += 2) {
// Left column
display->drawString(x, currentY, entries[i]);
// Right column if it exists
if (i + 1 < entries.size()) {
int rightX = SCREEN_WIDTH / 2;
display->drawString(rightX, currentY, entries[i + 1]);
}
currentY += rowHeight;
} }
} }
@ -131,6 +224,10 @@ bool AirQualityTelemetryModule::handleReceivedProtobuf(const meshtastic_MeshPack
LOG_INFO(" | PM1.0(Environmental)=%i, PM2.5(Environmental)=%i, PM10.0(Environmental)=%i", 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.pm10_environmental, t->variant.air_quality_metrics.pm25_environmental,
t->variant.air_quality_metrics.pm100_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 #endif
// release previous packet before occupying a new spot // release previous packet before occupying a new spot
if (lastMeasurementPacket != nullptr) if (lastMeasurementPacket != nullptr)
@ -144,35 +241,25 @@ bool AirQualityTelemetryModule::handleReceivedProtobuf(const meshtastic_MeshPack
bool AirQualityTelemetryModule::getAirQualityTelemetry(meshtastic_Telemetry *m) bool AirQualityTelemetryModule::getAirQualityTelemetry(meshtastic_Telemetry *m)
{ {
if (!aqi.read(&data)) { bool valid = true;
LOG_WARN("Skip send measurements. Could not read AQIn"); bool hasSensor = false;
return false;
}
m->time = getTime(); m->time = getTime();
m->which_variant = meshtastic_Telemetry_air_quality_metrics_tag; m->which_variant = meshtastic_Telemetry_air_quality_metrics_tag;
m->variant.air_quality_metrics.has_pm10_standard = true; m->variant.air_quality_metrics = meshtastic_AirQualityMetrics_init_zero;
m->variant.air_quality_metrics.pm10_standard = data.pm10_standard;
m->variant.air_quality_metrics.has_pm25_standard = true;
m->variant.air_quality_metrics.pm25_standard = data.pm25_standard;
m->variant.air_quality_metrics.has_pm100_standard = true;
m->variant.air_quality_metrics.pm100_standard = data.pm100_standard;
m->variant.air_quality_metrics.has_pm10_environmental = true; if (pmsa003iSensor.hasSensor()) {
m->variant.air_quality_metrics.pm10_environmental = data.pm10_env; // TODO - Should we check for sensor state here?
m->variant.air_quality_metrics.has_pm25_environmental = true; // If a sensor is sleeping, we should know and check to wake it up
m->variant.air_quality_metrics.pm25_environmental = data.pm25_env; valid = valid && pmsa003iSensor.getMetrics(m);
m->variant.air_quality_metrics.has_pm100_environmental = true; hasSensor = true;
m->variant.air_quality_metrics.pm100_environmental = data.pm100_env; }
LOG_INFO("Send: PM1.0(Standard)=%i, PM2.5(Standard)=%i, PM10.0(Standard)=%i", m->variant.air_quality_metrics.pm10_standard, if (scd4xSensor.hasSensor()) {
m->variant.air_quality_metrics.pm25_standard, m->variant.air_quality_metrics.pm100_standard); valid = valid && scd4xSensor.getMetrics(m);
hasSensor = true;
}
LOG_INFO(" | PM1.0(Environmental)=%i, PM2.5(Environmental)=%i, PM10.0(Environmental)=%i", return valid && hasSensor;
m->variant.air_quality_metrics.pm10_environmental, m->variant.air_quality_metrics.pm25_environmental,
m->variant.air_quality_metrics.pm100_environmental);
return true;
} }
meshtastic_MeshPacket *AirQualityTelemetryModule::allocReply() meshtastic_MeshPacket *AirQualityTelemetryModule::allocReply()
@ -206,7 +293,29 @@ meshtastic_MeshPacket *AirQualityTelemetryModule::allocReply()
bool AirQualityTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) bool AirQualityTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
{ {
meshtastic_Telemetry m = meshtastic_Telemetry_init_zero; meshtastic_Telemetry m = meshtastic_Telemetry_init_zero;
m.which_variant = meshtastic_Telemetry_air_quality_metrics_tag;
m.time = getTime();
if (getAirQualityTelemetry(&m)) { if (getAirQualityTelemetry(&m)) {
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, \
pm10_environmental=%u, pm25_environmental=%u, pm100_environmental=%u", \
m.variant.air_quality_metrics.pm10_standard, m.variant.air_quality_metrics.pm25_standard, \
m.variant.air_quality_metrics.pm100_standard, 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); meshtastic_MeshPacket *p = allocDataProtobuf(m);
p->to = dest; p->to = dest;
p->decoded.want_response = false; p->decoded.want_response = false;
@ -221,16 +330,54 @@ bool AirQualityTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
lastMeasurementPacket = packetPool.allocCopy(*p); lastMeasurementPacket = packetPool.allocCopy(*p);
if (phoneOnly) { if (phoneOnly) {
LOG_INFO("Send packet to phone"); LOG_INFO("Sending packet to phone");
service->sendToPhone(p); service->sendToPhone(p);
} else { } else {
LOG_INFO("Send packet to mesh"); LOG_INFO("Sending packet to mesh");
service->sendToMesh(p, RX_SRC_LOCAL, true); service->sendToMesh(p, RX_SRC_LOCAL, true);
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR && config.power.is_power_saving) {
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
notification->level = meshtastic_LogRecord_Level_INFO;
notification->time = getValidTime(RTCQualityFromNet);
sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment",
Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.air_quality_interval,
default_telemetry_broadcast_interval_secs) /
1000U);
service->sendClientNotification(notification);
sleepOnNextExecution = true;
LOG_DEBUG("Start next execution in 5s, then sleep");
setIntervalFromNow(FIVE_SECONDS_MS);
}
} }
return true; return true;
} }
return false; return false;
} }
AdminMessageHandleResult AirQualityTelemetryModule::handleAdminMessageForModule(const meshtastic_MeshPacket &mp,
meshtastic_AdminMessage *request,
meshtastic_AdminMessage *response)
{
AdminMessageHandleResult result = AdminMessageHandleResult::NOT_HANDLED;
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR_EXTERNAL
if (pmsa003iSensor.hasSensor()) {
// TODO - Potentially implement an admin message to choose between pm_standard
// and pm_environmental. This could be configurable as it doesn't make sense so
// have both
result = pmsa003iSensor.handleAdminMessage(mp, request, response);
if (result != AdminMessageHandleResult::NOT_HANDLED)
return result;
}
if (scd4xSensor.hasSensor()) {
result = scd4xSensor.handleAdminMessage(mp, request, response);
if (result != AdminMessageHandleResult::NOT_HANDLED)
return result;
}
#endif
return result;
}
#endif #endif

View File

@ -1,12 +1,18 @@
#include "configuration.h" #include "configuration.h"
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include("Adafruit_PM25AQI.h") #if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
#pragma once #pragma once
#ifndef AIR_QUALITY_TELEMETRY_MODULE_ENABLE
#define AIR_QUALITY_TELEMETRY_MODULE_ENABLE 0
#endif
#include "../mesh/generated/meshtastic/telemetry.pb.h" #include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "Adafruit_PM25AQI.h"
#include "NodeDB.h" #include "NodeDB.h"
#include "ProtobufModule.h" #include "ProtobufModule.h"
#include <OLEDDisplay.h>
#include <OLEDDisplayUi.h>
class AirQualityTelemetryModule : private concurrency::OSThread, public ProtobufModule<meshtastic_Telemetry> class AirQualityTelemetryModule : private concurrency::OSThread, public ProtobufModule<meshtastic_Telemetry>
{ {
@ -20,18 +26,15 @@ class AirQualityTelemetryModule : private concurrency::OSThread, public Protobuf
ProtobufModule("AirQualityTelemetry", meshtastic_PortNum_TELEMETRY_APP, &meshtastic_Telemetry_msg) ProtobufModule("AirQualityTelemetry", meshtastic_PortNum_TELEMETRY_APP, &meshtastic_Telemetry_msg)
{ {
lastMeasurementPacket = nullptr; lastMeasurementPacket = nullptr;
setIntervalFromNow(10 * 1000);
aqi = Adafruit_PM25AQI();
nodeStatusObserver.observe(&nodeStatus->onNewStatus); nodeStatusObserver.observe(&nodeStatus->onNewStatus);
setIntervalFromNow(10 * 1000);
#ifdef PMSA003I_ENABLE_PIN
// the PMSA003I sensor uses about 300mW on its own; support powering it off when it's not actively taking
// a reading
state = State::IDLE;
#else
state = State::ACTIVE;
#endif
} }
virtual bool wantUIFrame() override;
#if !HAS_SCREEN
void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
#else
virtual void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) override;
#endif
protected: protected:
/** Called to handle a particular incoming message /** Called to handle a particular incoming message
@ -49,19 +52,15 @@ class AirQualityTelemetryModule : private concurrency::OSThread, public Protobuf
*/ */
bool sendTelemetry(NodeNum dest = NODENUM_BROADCAST, bool wantReplies = false); bool sendTelemetry(NodeNum dest = NODENUM_BROADCAST, bool wantReplies = false);
virtual AdminMessageHandleResult handleAdminMessageForModule(const meshtastic_MeshPacket &mp,
meshtastic_AdminMessage *request,
meshtastic_AdminMessage *response) override;
private: private:
enum State {
IDLE = 0,
ACTIVE = 1,
};
State state;
Adafruit_PM25AQI aqi;
PM25_AQI_Data data = {0};
bool firstTime = true; bool firstTime = true;
meshtastic_MeshPacket *lastMeasurementPacket; meshtastic_MeshPacket *lastMeasurementPacket;
uint32_t sendToPhoneIntervalMs = SECONDS_IN_MINUTE * 1000; // Send to phone every minute uint32_t sendToPhoneIntervalMs = SECONDS_IN_MINUTE * 1000; // Send to phone every minute
uint32_t lastSentToMesh = 0; uint32_t lastSentToMesh = 0;
uint32_t lastSentToPhone = 0;
}; };
#endif #endif

View File

@ -743,8 +743,6 @@ bool EnvironmentTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
LOG_INFO("Send: soil_temperature=%f, soil_moisture=%u", m.variant.environment_metrics.soil_temperature, LOG_INFO("Send: soil_temperature=%f, soil_moisture=%u", m.variant.environment_metrics.soil_temperature,
m.variant.environment_metrics.soil_moisture); m.variant.environment_metrics.soil_moisture);
sensor_read_error_count = 0;
meshtastic_MeshPacket *p = allocDataProtobuf(m); meshtastic_MeshPacket *p = allocDataProtobuf(m);
p->to = dest; p->to = dest;
p->decoded.want_response = false; p->decoded.want_response = false;

View File

@ -62,7 +62,6 @@ class EnvironmentTelemetryModule : private concurrency::OSThread, public Protobu
uint32_t sendToPhoneIntervalMs = SECONDS_IN_MINUTE * 1000; // Send to phone every minute uint32_t sendToPhoneIntervalMs = SECONDS_IN_MINUTE * 1000; // Send to phone every minute
uint32_t lastSentToMesh = 0; uint32_t lastSentToMesh = 0;
uint32_t lastSentToPhone = 0; uint32_t lastSentToPhone = 0;
uint32_t sensor_read_error_count = 0;
}; };
#endif #endif

View File

@ -0,0 +1,175 @@
#include "configuration.h"
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
#include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "PMSA003ISensor.h"
#include "TelemetrySensor.h"
#include <Wire.h>
PMSA003ISensor::PMSA003ISensor()
: TelemetrySensor(meshtastic_TelemetrySensorType_PMSA003I, "PMSA003I")
{
}
void PMSA003ISensor::setup()
{
#ifdef PMSA003I_ENABLE_PIN
pinMode(PMSA003I_ENABLE_PIN, OUTPUT);
#endif
}
bool PMSA003ISensor::restoreClock(uint32_t currentClock){
#ifdef PMSA003I_I2C_CLOCK_SPEED
if (currentClock != PMSA003I_I2C_CLOCK_SPEED){
// LOG_DEBUG("Restoring I2C clock to %uHz", currentClock);
return bus->setClock(currentClock);
}
return true;
#endif
}
int32_t PMSA003ISensor::runOnce()
{
LOG_INFO("Init sensor: %s", sensorName);
if (!hasSensor()) {
return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS;
}
bus = nodeTelemetrySensorsMap[sensorType].second;
address = (uint8_t)nodeTelemetrySensorsMap[sensorType].first;
#ifdef PMSA003I_I2C_CLOCK_SPEED
uint32_t currentClock;
currentClock = bus->getClock();
if (currentClock != PMSA003I_I2C_CLOCK_SPEED){
// LOG_DEBUG("Changing I2C clock to %u", PMSA003I_I2C_CLOCK_SPEED);
bus->setClock(PMSA003I_I2C_CLOCK_SPEED);
}
#endif
bus->beginTransmission(address);
if (bus->endTransmission() != 0) {
LOG_WARN("PMSA003I not found on I2C at 0x12");
return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS;
}
restoreClock(currentClock);
status = 1;
LOG_INFO("PMSA003I Enabled");
return initI2CSensor();
}
bool PMSA003ISensor::getMetrics(meshtastic_Telemetry *measurement)
{
if(!isActive()){
LOG_WARN("PMSA003I is not active");
return false;
}
#ifdef PMSA003I_I2C_CLOCK_SPEED
uint32_t currentClock;
currentClock = bus->getClock();
if (currentClock != PMSA003I_I2C_CLOCK_SPEED){
// LOG_DEBUG("Changing I2C clock to %u", PMSA003I_I2C_CLOCK_SPEED);
bus->setClock(PMSA003I_I2C_CLOCK_SPEED);
}
#endif
bus->requestFrom(address, PMSA003I_FRAME_LENGTH);
if (bus->available() < PMSA003I_FRAME_LENGTH) {
LOG_WARN("PMSA003I read failed: incomplete data (%d bytes)", bus->available());
return false;
}
restoreClock(currentClock);
for (uint8_t i = 0; i < PMSA003I_FRAME_LENGTH; i++) {
buffer[i] = bus->read();
}
if (buffer[0] != 0x42 || buffer[1] != 0x4D) {
LOG_WARN("PMSA003I frame header invalid: 0x%02X 0x%02X", buffer[0], buffer[1]);
return false;
}
auto read16 = [](uint8_t *data, uint8_t idx) -> uint16_t {
return (data[idx] << 8) | data[idx + 1];
};
computedChecksum = 0;
for (uint8_t i = 0; i < PMSA003I_FRAME_LENGTH - 2; i++) {
computedChecksum += buffer[i];
}
receivedChecksum = read16(buffer, PMSA003I_FRAME_LENGTH - 2);
if (computedChecksum != receivedChecksum) {
LOG_WARN("PMSA003I checksum failed: computed 0x%04X, received 0x%04X", computedChecksum, receivedChecksum);
return false;
}
measurement->variant.air_quality_metrics.has_pm10_standard = true;
measurement->variant.air_quality_metrics.pm10_standard = read16(buffer, 4);
measurement->variant.air_quality_metrics.has_pm25_standard = true;
measurement->variant.air_quality_metrics.pm25_standard = read16(buffer, 6);
measurement->variant.air_quality_metrics.has_pm100_standard = true;
measurement->variant.air_quality_metrics.pm100_standard = read16(buffer, 8);
measurement->variant.air_quality_metrics.has_pm10_environmental = true;
measurement->variant.air_quality_metrics.pm10_environmental = read16(buffer, 10);
measurement->variant.air_quality_metrics.has_pm25_environmental = true;
measurement->variant.air_quality_metrics.pm25_environmental = read16(buffer, 12);
measurement->variant.air_quality_metrics.has_pm100_environmental = true;
measurement->variant.air_quality_metrics.pm100_environmental = read16(buffer, 14);
measurement->variant.air_quality_metrics.has_particles_03um = true;
measurement->variant.air_quality_metrics.particles_03um = read16(buffer, 16);
measurement->variant.air_quality_metrics.has_particles_05um = true;
measurement->variant.air_quality_metrics.particles_05um = read16(buffer, 18);
measurement->variant.air_quality_metrics.has_particles_10um = true;
measurement->variant.air_quality_metrics.particles_10um = read16(buffer, 20);
measurement->variant.air_quality_metrics.has_particles_25um = true;
measurement->variant.air_quality_metrics.particles_25um = read16(buffer, 22);
measurement->variant.air_quality_metrics.has_particles_50um = true;
measurement->variant.air_quality_metrics.particles_50um = read16(buffer, 24);
measurement->variant.air_quality_metrics.has_particles_100um = true;
measurement->variant.air_quality_metrics.particles_100um = read16(buffer, 26);
return true;
}
bool PMSA003ISensor::isActive()
{
return state == State::ACTIVE;
}
#ifdef PMSA003I_ENABLE_PIN
void PMSA003ISensor::sleep()
{
digitalWrite(PMSA003I_ENABLE_PIN, LOW);
state = State::IDLE;
}
uint32_t PMSA003ISensor::wakeUp()
{
digitalWrite(PMSA003I_ENABLE_PIN, HIGH);
state = State::ACTIVE;
return PMSA003I_WARMUP_MS;
}
#endif
#endif

View File

@ -0,0 +1,35 @@
#pragma once
#include "TelemetrySensor.h"
#define PMSA003I_I2C_CLOCK_SPEED 100000
#define PMSA003I_FRAME_LENGTH 32
#define PMSA003I_WARMUP_MS 30000
class PMSA003ISensor : public TelemetrySensor
{
public:
PMSA003ISensor();
virtual void setup() override;
virtual int32_t runOnce() override;
virtual bool restoreClock(uint32_t currentClock);
virtual bool getMetrics(meshtastic_Telemetry *measurement) override;
virtual bool isActive();
#ifdef PMSA003I_ENABLE_PIN
void sleep();
uint32_t wakeUp();
#endif
private:
enum class State { IDLE, ACTIVE };
State state = State::ACTIVE;
TwoWire * bus;
uint8_t address;
uint16_t computedChecksum = 0;
uint16_t receivedChecksum = 0;
uint8_t buffer[PMSA003I_FRAME_LENGTH];
};

View File

@ -0,0 +1,721 @@
#include "configuration.h"
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include(<SensirionI2cScd4x.h>)
#include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "SCD4XSensor.h"
#include "TelemetrySensor.h"
#include <SensirionI2cScd4x.h>
#define SCD4X_NO_ERROR 0
SCD4XSensor::SCD4XSensor() : TelemetrySensor(meshtastic_TelemetrySensorType_SCD4X, "SCD4X") {}
#ifdef SCD4X_I2C_CLOCK_SPEED
uint32_t SCD4XSensor::setI2CClock(uint32_t desiredClock){
uint32_t currentClock;
currentClock = bus->getClock();
LOG_DEBUG("Current I2C clock: %uHz", currentClock);
if (currentClock != desiredClock){
LOG_DEBUG("Setting I2C clock to: %uHz", desiredClock);
bus->setClock(desiredClock);
return currentClock;
}
return 0;
}
#endif
int32_t SCD4XSensor::runOnce()
{
LOG_INFO("Init sensor: %s", sensorName);
if (!hasSensor()) {
return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS;
}
bus = nodeTelemetrySensorsMap[sensorType].second;
address = (uint8_t)nodeTelemetrySensorsMap[sensorType].first;
#ifdef SCD4X_I2C_CLOCK_SPEED
uint32_t currentClock;
currentClock = setI2CClock(SCD4X_I2C_CLOCK_SPEED);
#endif
// FIXME - This should be based on bus and address from above
scd4x.begin(*nodeTelemetrySensorsMap[sensorType].second,
address);
// SCD4X library
delay(30);
// Stop periodic measurement
if (!stopMeasurement()) {
return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS;
}
// Get sensor variant
scd4x.getSensorVariant(sensorVariant);
if (sensorVariant == SCD4X_SENSOR_VARIANT_SCD41){
LOG_INFO("SCD4X: Found SCD41");
if (!wakeUp()) {
LOG_ERROR("SCD4X: Error trying to execute wakeUp()");
return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS;
}
}
if (!getASC(ascActive)){
LOG_ERROR("SCD4X: Unable to check if ASC is enabled");
return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS;
}
// Start measurement in selected power mode (low power by default)
if (!startMeasurement()){
LOG_ERROR("SCD4X: Couldn't start measurement");
return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS;
}
#ifdef SCD4X_I2C_CLOCK_SPEED
if (currentClock){
setI2CClock(currentClock);
}
#endif
if (state == SCD4X_MEASUREMENT){
status = 1;
} else {
status = 0;
}
return initI2CSensor();
}
void SCD4XSensor::setup() {}
bool SCD4XSensor::getMetrics(meshtastic_Telemetry *measurement)
{
if (state != SCD4X_MEASUREMENT) {
LOG_ERROR("SCD4X: Not in measurement mode");
return false;
}
uint16_t co2, error;
float temperature, humidity;
#ifdef SCD4X_I2C_CLOCK_SPEED
uint32_t currentClock;
currentClock = setI2CClock(SCD4X_I2C_CLOCK_SPEED);
#endif
bool dataReady;
error = scd4x.getDataReadyStatus(dataReady);
if (!dataReady) {
LOG_ERROR("SCD4X: Data is not ready");
return false;
}
error = scd4x.readMeasurement(co2, temperature, humidity);
#ifdef SCD4X_I2C_CLOCK_SPEED
if (currentClock){
setI2CClock(currentClock);
}
#endif
LOG_DEBUG("SCD4X readings: %u ppm, %.2f degC, %.2f %rh", co2, temperature, humidity);
if (error != SCD4X_NO_ERROR) {
LOG_DEBUG("SCD4X: Error while getting measurements: %u", error);
if (co2 == 0) {
LOG_ERROR("SCD4X: Skipping invalid measurement.");
}
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.
*/
bool SCD4XSensor::performFRC(uint32_t targetCO2) {
uint16_t error, frcCorr;
LOG_INFO("SCD4X: Issuing FRC. Ensure device has been working at least 3 minutes in stable target environment");
if (!stopMeasurement()) {
return false;
}
LOG_INFO("SCD4X: Target CO2: %u ppm", targetCO2);
error = scd4x.performForcedRecalibration((uint16_t)targetCO2, frcCorr);
// SCD4X Sensirion datasheet
delay(400);
if (error != SCD4X_NO_ERROR){
LOG_ERROR("SCD4X: Unable to perform forced recalibration.");
return false;
}
if (frcCorr == 0xFFFF) {
LOG_ERROR("SCD4X: Error while performing forced recalibration.");
return false;
}
LOG_INFO("SCD4X: FRC Correction successful. Correction output: %u", (uint16_t)(frcCorr-0x8000));
return true;
}
bool SCD4XSensor::startMeasurement() {
uint16_t error;
if (state == SCD4X_MEASUREMENT){
LOG_DEBUG("SCD4X: Already in measurement mode");
return true;
}
if (lowPower) {
error = scd4x.startLowPowerPeriodicMeasurement();
} else {
error = scd4x.startPeriodicMeasurement();
}
if (error == SCD4X_NO_ERROR) {
LOG_INFO("SCD4X: Started measurement mode");
if (lowPower) {
LOG_INFO("SCD4X: Low power mode");
} else {
LOG_INFO("SCD4X: Normal power mode");
}
state = SCD4X_MEASUREMENT;
return true;
} else {
LOG_ERROR("SCD4X: Couldn't start measurement mode");
return false;
}
}
bool SCD4XSensor::stopMeasurement(){
uint16_t error;
error = scd4x.stopPeriodicMeasurement();
if (error != SCD4X_NO_ERROR) {
LOG_ERROR("SCD4X: Unable to set idle mode on SCD4X.");
return false;
}
state = SCD4X_IDLE;
return true;
}
bool SCD4XSensor::setPowerMode(bool _lowPower) {
lowPower = _lowPower;
if (!stopMeasurement()) {
return false;
}
if (lowPower) {
LOG_DEBUG("SCD4X: Set low power mode");
} else {
LOG_DEBUG("SCD4X: Set normal power mode");
}
return true;
}
/**
* @brief Check the current mode (ASC or FRC)
* From Sensirion SCD4X I2C Library
*/
bool SCD4XSensor::getASC(uint16_t &_ascActive) {
uint16_t error;
LOG_INFO("SCD4X: Getting ASC");
if (!stopMeasurement()) {
return false;
}
error = scd4x.getAutomaticSelfCalibrationEnabled(_ascActive);
if (error != SCD4X_NO_ERROR){
LOG_ERROR("SCD4X: Unable to send command.");
return false;
}
if (_ascActive){
LOG_INFO("SCD4X: ASC is enabled");
} else {
LOG_INFO("SCD4X: FRC is enabled");
}
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.
*/
bool SCD4XSensor::setASC(bool ascEnabled){
uint16_t error;
if (ascEnabled){
LOG_INFO("SCD4X: Enabling ASC");
} else {
LOG_INFO("SCD4X: Disabling ASC");
}
if (!stopMeasurement()) {
return false;
}
error = scd4x.setAutomaticSelfCalibrationEnabled((uint16_t)ascEnabled);
if (error != SCD4X_NO_ERROR){
LOG_ERROR("SCD4X: Unable to send command.");
return false;
}
error = scd4x.persistSettings();
if (error != SCD4X_NO_ERROR){
LOG_ERROR("SCD4X: Unable to make settings persistent.");
return false;
}
if (!getASC(ascActive)){
LOG_ERROR("SCD4X: Unable to check if ASC is enabled");
return false;
}
if (ascActive){
LOG_INFO("SCD4X: ASC is enabled");
} else {
LOG_INFO("SCD4X: ASC is disabled");
}
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.
*/
bool SCD4XSensor::setASCBaseline(uint32_t targetCO2){
// TODO - Remove?
// Available in library, but not described in datasheet.
uint16_t error;
LOG_INFO("SCD4X: Setting ASC baseline to: %u", targetCO2);
getASC(ascActive);
if (!ascActive){
LOG_ERROR("SCD4X: Can't set ASC baseline. ASC is not active");
return false;
}
if (!stopMeasurement()) {
return false;
}
error = scd4x.setAutomaticSelfCalibrationTarget((uint16_t)targetCO2);
if (error != SCD4X_NO_ERROR){
LOG_ERROR("SCD4X: Unable to send command.");
return false;
}
error = scd4x.persistSettings();
if (error != SCD4X_NO_ERROR){
LOG_ERROR("SCD4X: Unable to make settings persistent.");
return false;
}
LOG_INFO("SCD4X: Setting ASC baseline successful");
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.
*/
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("SCD4X: Setting reference temperature at: %.2f", tempReference);
error = scd4x.getDataReadyStatus(dataReady);
if (!dataReady) {
LOG_ERROR("SCD4X: Data is not ready");
return false;
}
error = scd4x.readMeasurement(co2, temperature, humidity);
if (error != SCD4X_NO_ERROR) {
LOG_ERROR("SCD4X: Unable to read current temperature. Error code: %u", error);
return false;
}
LOG_INFO("SCD4X: Current sensor temperature: %.2f", temperature);
if (!stopMeasurement()) {
return false;
}
error = scd4x.getTemperatureOffset(prevTempOffset);
if (error != SCD4X_NO_ERROR){
LOG_ERROR("SCD4X: Unable to get temperature offset. Error code: %u", error);
return false;
}
LOG_INFO("SCD4X: Current sensor temperature offset: %.2f", prevTempOffset);
tempOffset = temperature - tempReference + prevTempOffset;
LOG_INFO("SCD4X: Setting temperature offset: %.2f", tempOffset);
error = scd4x.setTemperatureOffset(tempOffset);
if (error != SCD4X_NO_ERROR){
LOG_ERROR("SCD4X: Unable to set temperature offset. Error code: %u", error);
return false;
}
error = scd4x.persistSettings();
if (error != SCD4X_NO_ERROR){
LOG_ERROR("SCD4X: Unable to make settings persistent. Error code: %u", error);
return false;
}
scd4x.getTemperatureOffset(updatedTempOffset);
LOG_INFO("SCD4X: Updated sensor temperature offset: %.2f", 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.
*/
bool SCD4XSensor::getAltitude(uint16_t &altitude){
uint16_t error;
LOG_INFO("SCD4X: Requesting sensor altitude");
if (!stopMeasurement()) {
return false;
}
error = scd4x.getSensorAltitude(altitude);
if (error != SCD4X_NO_ERROR){
LOG_ERROR("SCD4X: Unable to get altitude. Error code: %u", error);
return false;
}
LOG_INFO("SCD4X: Sensor altitude: %u", altitude);
return true;
}
/**
* @brief Get the ambient pressure around the sensor.
*
* From Sensirion SCD4X I2C Library.
*
* Gets the ambient pressure in Pa.
*/
bool SCD4XSensor::getAmbientPressure(uint32_t &ambientPressure){
uint16_t error;
LOG_INFO("SCD4X: Requesting sensor ambient pressure");
error = scd4x.getAmbientPressure(ambientPressure);
if (error != SCD4X_NO_ERROR){
LOG_ERROR("SCD4X: Unable to get altitude. Error code: %u", error);
return false;
}
LOG_INFO("SCD4X: Sensor ambient pressure: %u", 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.
*/
bool SCD4XSensor::setAltitude(uint32_t altitude){
uint16_t error;
if (!stopMeasurement()) {
return false;
}
error = scd4x.setSensorAltitude(altitude);
if (error != SCD4X_NO_ERROR){
LOG_ERROR("SCD4X: Unable to set altitude. Error code: %u", error);
return false;
}
error = scd4x.persistSettings();
if (error != SCD4X_NO_ERROR){
LOG_ERROR("SCD4X: Unable to make settings persistent. Error code: %u", 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.
*/
bool SCD4XSensor::setAmbientPressure(uint32_t ambientPressure) {
uint16_t error;
error = scd4x.setAmbientPressure(ambientPressure);
if (error != SCD4X_NO_ERROR){
LOG_ERROR("SCD4X: Unable to set altitude. Error code: %u", 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("SCD4X: Unable to make settings persistent. Error code: %u", 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.
*/
bool SCD4XSensor::factoryReset() {
uint16_t error;
LOG_INFO("SCD4X: Requesting factory reset");
if (!stopMeasurement()) {
return false;
}
error = scd4x.performFactoryReset();
if (error != SCD4X_NO_ERROR){
LOG_ERROR("SCD4X: Unable to do factory reset. Error code: %u", error);
return false;
}
LOG_INFO("SCD4X: Factory reset successful");
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::sleep() {
LOG_INFO("SCD4X: Powering down");
if (sensorVariant != SCD4X_SENSOR_VARIANT_SCD41) {
LOG_WARN("SCD4X: Can't send sensor to sleep. Incorrect variant. Ignoring");
return true;
}
if (!stopMeasurement()) {
return false;
}
if (scd4x.powerDown() != SCD4X_NO_ERROR) {
LOG_ERROR("SCD4X: Error trying to execute wakeUp()");
return false;
}
state = SCD4X_OFF;
return true;
}
/**
* @brief Wake up sensor from sleep mode to idle mode.
*
* 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.
*/
bool SCD4XSensor::wakeUp(){
LOG_INFO("SCD4X: Waking up");
if (scd4x.wakeUp() != SCD4X_NO_ERROR) {
LOG_ERROR("SCD4X: Error trying to execute wakeUp()");
return false;
}
state = SCD4X_IDLE;
return true;
}
AdminMessageHandleResult SCD4XSensor::handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request,
meshtastic_AdminMessage *response)
{
AdminMessageHandleResult result;
// 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("SCD4X: Requested factory reset");
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("SCD4X: Request for FRC");
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("SCD4X: target CO2 not provided");
result = AdminMessageHandleResult::NOT_HANDLED;
break;
}
} else {
LOG_DEBUG("SCD4X: Request for ASC");
if (request->sensor_config.scd4x_config.has_set_target_co2_conc) {
LOG_DEBUG("SCD4X: Request has target CO2");
// TODO - Remove? see setASCBaseline function
this->setASCBaseline(request->sensor_config.scd4x_config.set_target_co2_conc);
} else {
LOG_DEBUG("SCD4X: Request doesn't have target CO2");
}
}
}
// 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;
}
return result;
}
#endif

View File

@ -0,0 +1,55 @@
#include "configuration.h"
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include(<SensirionI2cScd4x.h>)
#include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "TelemetrySensor.h"
#include <SensirionI2cScd4x.h>
#define SCD4X_I2C_CLOCK_SPEED 100000
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);
#ifdef SCD4X_I2C_CLOCK_SPEED
uint32_t setI2CClock(uint32_t currentClock);
#endif
bool factoryReset();
bool setPowerMode(bool _lowPower);
bool startMeasurement();
bool stopMeasurement();
// Parameters
uint16_t ascActive;
bool lowPower = true;
protected:
virtual void setup() override;
public:
SCD4XSensor();
enum SCD4XState { SCD4X_OFF, SCD4X_IDLE, SCD4X_MEASUREMENT };
SCD4XState state = SCD4X_OFF;
SCD4xSensorVariant sensorVariant;
bool sleep();
bool wakeUp();
virtual int32_t runOnce() override;
virtual bool getMetrics(meshtastic_Telemetry *measurement) override;
AdminMessageHandleResult handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request,
meshtastic_AdminMessage *response) override;
};
#endif