Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8fa513f
add CO2 sensing with SCD4X
Coloradohusky Aug 31, 2024
30532d4
Merge branch 'master' into scd4x
caveman99 Sep 2, 2024
e73ff62
Merge remote-tracking branch 'upstream/master' into scd4x
Coloradohusky Sep 26, 2024
f599984
update from upstream
Coloradohusky Sep 26, 2024
c9233fe
Merge remote-tracking branch 'upstream/master' into scd4x
Coloradohusky Oct 11, 2024
8a4427a
Merge remote-tracking branch 'upstream/master' into scd4x
Coloradohusky Oct 11, 2024
a28e83c
Merge branch 'master' into scd4x
fifieldt Oct 18, 2024
318da22
Abstract AirQualityTelemetry module
fifieldt Oct 18, 2024
8faf466
Re-add I2C scan, fix frame.
fifieldt Oct 19, 2024
81f9b38
Merge branch 'master' into scd4x
fifieldt Oct 19, 2024
e34aada
Merge branch 'master' into scd4x
thebentern Oct 20, 2024
16e2540
Merge branch 'master' into scd4x
caveman99 Nov 7, 2024
a1cbe8e
Merge branch 'master' into scd4x
fifieldt Nov 19, 2024
3cef1e8
Merge branch 'master' into scd4x
caveman99 Mar 31, 2025
ad7647e
Merge branch 'master' into scd4x
caveman99 Apr 7, 2025
7fe9efe
Wupps
caveman99 Apr 7, 2025
bac816d
Merge branch 'master' into scd4x
caveman99 Apr 11, 2025
726cf86
Merge branch 'master' into scd4x
fifieldt Jul 1, 2025
02a8414
Merge branch 'master' into scd4x
fifieldt Jul 1, 2025
58d916d
Fix merge
fifieldt Jul 1, 2025
ef203b5
Merge branch 'master' into scd4x
fifieldt Jul 1, 2025
cfc355b
Update main.cpp
fifieldt Jul 1, 2025
1eeadde
Merge branch 'master' into scd4x
fifieldt Jul 1, 2025
80175b1
Merge branch 'master' into scd4x
caveman99 Jul 13, 2025
f0775c5
Merge branch 'master' into scd4x
vidplace7 Jul 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ lib_deps =
adafruit/Adafruit TSL2591 Library@1.4.5
# renovate: datasource=custom.pio depName=EmotiBit MLX90632 packageName=emotibit/library/EmotiBit MLX90632
emotibit/EmotiBit MLX90632@1.0.8
sensirion/Sensirion I2C SCD4x@^0.4.0
# renovate: datasource=custom.pio depName=Adafruit MLX90614 packageName=adafruit/library/Adafruit MLX90614 Library
adafruit/Adafruit MLX90614 Library@2.1.5
# renovate: datasource=github-tags depName=INA3221 packageName=KodinLanewave/INA3221
Expand Down
1 change: 1 addition & 0 deletions src/configuration.h
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#define DFROBOT_LARK_ADDR 0x42
#define DFROBOT_RAIN_ADDR 0x1d
#define NAU7802_ADDR 0x2A
#define SCD4X_ADDR 0x62
#define MAX30102_ADDR 0x57
#define MLX90614_ADDR_DEF 0x5A
#define CGRADSENS_ADDR 0x66
Expand Down
6 changes: 6 additions & 0 deletions src/detect/ScanI2C.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const
return firstOfOrNONE(9, types);
}

ScanI2C::FoundDevice ScanI2C::firstAQI() const
{
ScanI2C::DeviceType types[] = {PMSA0031, SCD4X};
return firstOfOrNONE(2, types);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the intention. The PMSA003I measures the particles and the SCD4x CO2. When connecting both, only the values of the PMSA003I are measured and the SCD4x is ignored. Maybe differ between particles and co2, so that particles and co2 can be measured. Such an approach could be used if for example an SCD30 and SCD4x is connected.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally meshtastic only supports one sensor per type at a time.

ScanI2C::FoundDevice ScanI2C::firstRGBLED() const
{
ScanI2C::DeviceType types[] = {NCP5623, LP5562};
Expand Down
3 changes: 3 additions & 0 deletions src/detect/ScanI2C.h
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class ScanI2C
FT6336U,
STK8BAXX,
ICM20948,
SCD4X,
MAX30102,
TPS65233,
MPR121KB,
Expand Down Expand Up @@ -126,6 +127,8 @@ class ScanI2C

FoundDevice firstAccelerometer() const;

FoundDevice firstAQI() const;

FoundDevice firstRGBLED() const;

virtual FoundDevice find(DeviceType) const;
Expand Down
1 change: 1 addition & 0 deletions src/detect/ScanI2CTwoWire.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
SCAN_SIMPLE_CASE(MAX1704X_ADDR, MAX17048, "MAX17048", (uint8_t)addr.address);
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(SCD4X_ADDR, SCD4X, "SCD4X", (uint8_t)addr.address);
SCAN_SIMPLE_CASE(PCT2075_ADDR, PCT2075, "PCT2075", (uint8_t)addr.address);
SCAN_SIMPLE_CASE(BMM150_ADDR, BMM150, "BMM150", (uint8_t)addr.address);
#ifdef HAS_TPS65233
Expand Down
6 changes: 6 additions & 0 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ ScanI2C::DeviceAddress rtc_found = ScanI2C::ADDRESS_NONE;
ScanI2C::DeviceAddress accelerometer_found = ScanI2C::ADDRESS_NONE;
// The I2C address of the RGB LED (if found)
ScanI2C::FoundDevice rgb_found = ScanI2C::FoundDevice(ScanI2C::DeviceType::NONE, ScanI2C::ADDRESS_NONE);
/// The I2C address of our Air Quality Indicator (if found)
ScanI2C::DeviceAddress aqi_found = ScanI2C::ADDRESS_NONE;

#ifdef T_WATCH_S3
Adafruit_DRV2605 drv;
Expand Down Expand Up @@ -622,6 +624,9 @@ void setup()

pmu_found = i2cScanner->exists(ScanI2C::DeviceType::PMU_AXP192_AXP2101);

auto aqiInfo = i2cScanner->firstAQI();
aqi_found = aqiInfo.type != ScanI2C::DeviceType::NONE ? screenInfo.address : ScanI2C::ADDRESS_NONE;

/*
* There are a bunch of sensors that have no further logic than to be found and stuffed into the
* nodeTelemetrySensorsMap singleton. This wraps that logic in a temporary scope to declare the temporary field
Expand Down Expand Up @@ -688,6 +693,7 @@ void setup()
scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::DFROBOT_RAIN, meshtastic_TelemetrySensorType_DFROBOT_RAIN);
scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::LTR390UV, meshtastic_TelemetrySensorType_LTR390UV);
scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::DPS310, meshtastic_TelemetrySensorType_DPS310);
scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::SCD4X, meshtastic_TelemetrySensorType_SCD4X);
scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::RAK12035, meshtastic_TelemetrySensorType_RAK12035);
scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::PCT2075, meshtastic_TelemetrySensorType_PCT2075);

Expand Down
1 change: 1 addition & 0 deletions src/main.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ extern bool kb_found;
extern ScanI2C::DeviceAddress rtc_found;
extern ScanI2C::DeviceAddress accelerometer_found;
extern ScanI2C::FoundDevice rgb_found;
extern ScanI2C::DeviceAddress aqi_found;

extern bool eink_found;
extern bool pmu_found;
Expand Down
150 changes: 132 additions & 18 deletions src/modules/Telemetry/AirQualityTelemetry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,19 @@
#include "PowerFSM.h"
#include "RTC.h"
#include "Router.h"
#include "UnitConversions.h"
#include "detect/ScanI2CTwoWire.h"
#include "graphics/ScreenFonts.h"
#include "main.h"
#include "sleep.h"
#include <Throttle.h>

// Sensors
#include "Sensor/PMSA0031Sensor.h"
#include "Sensor/SCD4XSensor.h"

SCD4XSensor scd4xSensor;
PMSA0031Sensor pmsa0031Sensor;
#ifndef PMSA003I_WARMUP_MS
// from the PMSA003I datasheet:
// "Stable data should be got at least 30 seconds after the sensor wakeup
Expand All @@ -23,11 +32,20 @@

int32_t AirQualityTelemetryModule::runOnce()
{
if (sleepOnNextExecution == true) {
sleepOnNextExecution = false;
uint32_t nightyNightMs = Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.environment_update_interval,
default_telemetry_broadcast_interval_secs);
LOG_DEBUG("Sleeping for %ims, then awaking to send metrics again.", nightyNightMs);
doDeepSleep(nightyNightMs, true);
}

uint32_t result = UINT32_MAX;

/*
Uncomment the preferences below if you want to use the module
without having to configure it from the PythonAPI or WebUI.
*/

// moduleConfig.telemetry.air_quality_enabled = 1;

if (!(moduleConfig.telemetry.air_quality_enabled)) {
Expand All @@ -41,33 +59,64 @@ int32_t AirQualityTelemetryModule::runOnce()

if (moduleConfig.telemetry.air_quality_enabled) {
LOG_INFO("Air quality Telemetry: init");

#ifdef PMSA003I_ENABLE_PIN
// put the sensor to sleep on startup
pinMode(PMSA003I_ENABLE_PIN, OUTPUT);
digitalWrite(PMSA003I_ENABLE_PIN, LOW);
#endif /* PMSA003I_ENABLE_PIN */
if (aqi_found.address == 0x00) {


if (!aqi.begin_I2C()) {
#ifndef I2C_NO_RESCAN
LOG_WARN("Could not establish i2c connection to AQI sensor. Rescan");
LOG_WARN("Rescan for I2C AQI Sensor");
// 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;
uint8_t i2caddr_scan[] = {PMSA0031_ADDR, SCD4X_ADDR};
uint8_t i2caddr_asize = 2;
auto i2cScanner = std::unique_ptr<ScanI2CTwoWire>(new ScanI2CTwoWire());
#if defined(I2C_SDA1)

#if WIRE_INTERFACES_COUNT == 2
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
if (aqi_found.address == 0x00) {
return disable();
}
#endif
}
if (scd4xSensor.hasSensor())
result = scd4xSensor.runOnce();
if (pmsa0031Sensor.hasSensor())
result = pmsa0031Sensor.runOnce();
return result;

} else {
// if we somehow got to a second run of this module with measurement disabled, then just wait forever
if (!moduleConfig.telemetry.air_quality_enabled)
return disable();

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();
}
return setStartDelay();
}
Expand Down Expand Up @@ -115,6 +164,7 @@ int32_t AirQualityTelemetryModule::runOnce()
default:
return disable();
}
return min(sendToPhoneIntervalMs, result);
}
}

Expand All @@ -124,9 +174,9 @@ bool AirQualityTelemetryModule::handleReceivedProtobuf(const meshtastic_MeshPack
#ifdef DEBUG_PORT
const char *sender = getSenderShortName(mp);

LOG_INFO("(Received from %s): pm10_standard=%i, pm25_standard=%i, pm100_standard=%i", sender,
LOG_INFO("(Received from %s): pm10_standard=%i, pm25_standard=%i, pm100_standard=%i, co2=%i ppm", sender,
t->variant.air_quality_metrics.pm10_standard, t->variant.air_quality_metrics.pm25_standard,
t->variant.air_quality_metrics.pm100_standard);
t->variant.air_quality_metrics.pm100_standard, t->variant.air_quality_metrics.co2);

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,
Expand All @@ -142,13 +192,38 @@ bool AirQualityTelemetryModule::handleReceivedProtobuf(const meshtastic_MeshPack
return false; // Let others look at this message also if they want
}

bool AirQualityTelemetryModule::getAirQualityTelemetry(meshtastic_Telemetry *m)
bool AirQualityTelemetryModule::wantUIFrame()
{
return moduleConfig.telemetry.environment_screen_enabled;
}

void AirQualityTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
if (!aqi.read(&data)) {
LOG_WARN("Skip send measurements. Could not read AQIn");
return false;
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);

if (lastMeasurementPacket == nullptr) {
// If there's no valid packet, display "Environment"
display->drawString(x, y, "Air Quality");
display->drawString(x, y += _fontHeight(FONT_SMALL), "No measurement");
return;
}

// Decode the last measurement packet
meshtastic_Telemetry lastMeasurement;
uint32_t agoSecs = service->GetTimeSinceMeshPacket(lastMeasurementPacket);
const char *lastSender = getSenderShortName(*lastMeasurementPacket);

const meshtastic_Data &p = lastMeasurementPacket->decoded;
if (!pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_Telemetry_msg, &lastMeasurement)) {
display->drawString(x, y, "Measurement Error");
LOG_ERROR("Unable to decode last packet");
return;
}

// Display "Env. From: ..." on its own
display->drawString(x, y, "AQ. From: " + String(lastSender) + "(" + String(agoSecs) + "s)");

m->time = getTime();
m->which_variant = meshtastic_Telemetry_air_quality_metrics_tag;
m->variant.air_quality_metrics.has_pm10_standard = true;
Expand All @@ -168,11 +243,42 @@ bool AirQualityTelemetryModule::getAirQualityTelemetry(meshtastic_Telemetry *m)
LOG_INFO("Send: PM1.0(Standard)=%i, PM2.5(Standard)=%i, PM10.0(Standard)=%i", m->variant.air_quality_metrics.pm10_standard,
m->variant.air_quality_metrics.pm25_standard, m->variant.air_quality_metrics.pm100_standard);

LOG_INFO(" | PM1.0(Environmental)=%i, PM2.5(Environmental)=%i, PM10.0(Environmental)=%i",
m->variant.air_quality_metrics.pm10_environmental, m->variant.air_quality_metrics.pm25_environmental,
m->variant.air_quality_metrics.pm100_environmental);
if (lastMeasurement.variant.air_quality_metrics.has_pm10_standard) {
display->drawString(x, y += _fontHeight(FONT_SMALL),
"PM1.0(Standard): " + String(lastMeasurement.variant.air_quality_metrics.pm10_standard, 0));
}
if (lastMeasurement.variant.air_quality_metrics.has_pm25_standard) {
display->drawString(x, y += _fontHeight(FONT_SMALL),
"PM2.5(Standard): " + String(lastMeasurement.variant.air_quality_metrics.pm25_standard, 0));
}
if (lastMeasurement.variant.air_quality_metrics.has_pm10_environmental) {
display->drawString(x, y += _fontHeight(FONT_SMALL),
"PM10.0(Standard): " + String(lastMeasurement.variant.air_quality_metrics.pm100_standard, 0));
}
if (lastMeasurement.variant.air_quality_metrics.has_co2) {
display->drawString(x, y += _fontHeight(FONT_SMALL),
"CO2: " + String(lastMeasurement.variant.air_quality_metrics.co2, 0) + " ppm");
}
}

bool AirQualityTelemetryModule::getAirQualityTelemetry(meshtastic_Telemetry *m)
{
bool valid = true;
bool hasSensor = false;
m->time = getTime();
m->which_variant = meshtastic_Telemetry_air_quality_metrics_tag;
m->variant.air_quality_metrics = meshtastic_AirQualityMetrics_init_zero;

if (scd4xSensor.hasSensor()) {
valid = valid && scd4xSensor.getMetrics(m);
hasSensor = true;
}
if (pmsa0031Sensor.hasSensor()) {
valid = valid && pmsa0031Sensor.getMetrics(m);
hasSensor = true;
}

return true;
return valid && hasSensor;
}

meshtastic_MeshPacket *AirQualityTelemetryModule::allocReply()
Expand Down Expand Up @@ -207,6 +313,14 @@ bool AirQualityTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
{
meshtastic_Telemetry m = meshtastic_Telemetry_init_zero;
if (getAirQualityTelemetry(&m)) {
LOG_INFO("(Sending): PM1.0(Standard)=%i, PM2.5(Standard)=%i, PM10.0(Standard)=%i, cO2=%i ppm",
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.co2);

LOG_INFO(" | PM1.0(Environmental)=%i, PM2.5(Environmental)=%i, PM10.0(Environmental)=%i",
m.variant.air_quality_metrics.pm10_environmental, m.variant.air_quality_metrics.pm25_environmental,
m.variant.air_quality_metrics.pm100_environmental);

meshtastic_MeshPacket *p = allocDataProtobuf(m);
p->to = dest;
p->decoded.want_response = false;
Expand Down
14 changes: 11 additions & 3 deletions src/modules/Telemetry/AirQualityTelemetry.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

#pragma once
#include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "Adafruit_PM25AQI.h"
#include "NodeDB.h"
#include "ProtobufModule.h"
#include <OLEDDisplay.h>
#include <OLEDDisplayUi.h>

class AirQualityTelemetryModule : private concurrency::OSThread, public ProtobufModule<meshtastic_Telemetry>
{
Expand All @@ -20,9 +21,8 @@ class AirQualityTelemetryModule : private concurrency::OSThread, public Protobuf
ProtobufModule("AirQualityTelemetry", meshtastic_PortNum_TELEMETRY_APP, &meshtastic_Telemetry_msg)
{
lastMeasurementPacket = nullptr;
setIntervalFromNow(10 * 1000);
aqi = Adafruit_PM25AQI();
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
Expand All @@ -32,6 +32,12 @@ class AirQualityTelemetryModule : private concurrency::OSThread, public Protobuf
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:
/** Called to handle a particular incoming message
Expand Down Expand Up @@ -62,6 +68,8 @@ class AirQualityTelemetryModule : private concurrency::OSThread, public Protobuf
meshtastic_MeshPacket *lastMeasurementPacket;
uint32_t sendToPhoneIntervalMs = SECONDS_IN_MINUTE * 1000; // Send to phone every minute
uint32_t lastSentToMesh = 0;
uint32_t lastSentToPhone = 0;
uint32_t sensor_read_error_count = 0;
};

#endif
1 change: 1 addition & 0 deletions src/modules/Telemetry/EnvironmentTelemetry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ bool EnvironmentTelemetryModule::handleReceivedProtobuf(const meshtastic_MeshPac
sender, t->variant.environment_metrics.barometric_pressure, t->variant.environment_metrics.current,
t->variant.environment_metrics.gas_resistance, t->variant.environment_metrics.relative_humidity,
t->variant.environment_metrics.temperature);

LOG_INFO("(Received from %s): voltage=%f, IAQ=%d, distance=%f, lux=%f, white_lux=%f", sender,
t->variant.environment_metrics.voltage, t->variant.environment_metrics.iaq,
t->variant.environment_metrics.distance, t->variant.environment_metrics.lux,
Expand Down
Loading
Loading