diff --git a/.github/workflows/models_pr_triage.yml b/.github/workflows/models_pr_triage.yml index ef303c02a4f..d4c8509d26b 100644 --- a/.github/workflows/models_pr_triage.yml +++ b/.github/workflows/models_pr_triage.yml @@ -88,9 +88,10 @@ jobs: # ───────────────────────────────────────────────────────────────────────── # Step 3: Auto-label PR type (bugfix/hardware-support/enhancement) + # Only skip for spam/ai-generated; still classify needs-review PRs # ───────────────────────────────────────────────────────────────────────── - name: Classify PR for labeling - if: steps.check-labels.outputs.skip_all != 'true' && steps.check-labels.outputs.has_type_label != 'true' && (steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == '') + if: steps.check-labels.outputs.skip_all != 'true' && steps.check-labels.outputs.has_type_label != 'true' && steps.quality.outputs.response != 'spam' && steps.quality.outputs.response != 'ai-generated' uses: actions/ai-inference@v2 id: classify continue-on-error: true diff --git a/platformio.ini b/platformio.ini index 25fc8829fdd..f9a7351c3f1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -213,6 +213,7 @@ 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 + ; Same as environmental_extra but without BSEC (saves ~3.5KB DRAM for original ESP32 targets) [environmental_extra_no_bsec] lib_deps = @@ -239,4 +240,4 @@ lib_deps = # renovate: datasource=custom.pio depName=Sensirion Core packageName=sensirion/library/Sensirion Core 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 \ No newline at end of file + sensirion/Sensirion I2C SCD4x@1.1.0 diff --git a/src/configuration.h b/src/configuration.h index f7b438272a4..1a92078e491 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -241,6 +241,7 @@ along with this program. If not, see . #define BQ27220_ADDR 0x55 // same address as TDECK_KB #define BQ25896_ADDR 0x6B #define LTR553ALS_ADDR 0x23 +#define SEN5X_ADDR 0x69 // ----------------------------------------------------------------------------- // ACCELEROMETER diff --git a/src/detect/ScanI2C.cpp b/src/detect/ScanI2C.cpp index 4795d2abc17..bf01a03657d 100644 --- a/src/detect/ScanI2C.cpp +++ b/src/detect/ScanI2C.cpp @@ -43,7 +43,7 @@ ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const ScanI2C::FoundDevice ScanI2C::firstAQI() const { - ScanI2C::DeviceType types[] = {PMSA003I, SCD4X}; + ScanI2C::DeviceType types[] = {PMSA003I, SEN5X, SCD4X}; return firstOfOrNONE(2, types); } diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index dffcd8fb65a..3910ddf64c2 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -88,7 +88,8 @@ class ScanI2C BH1750, DA217, CHSC6X, - CST226SE + CST226SE, + SEN5X } DeviceType; // typedef uint8_t DeviceAddress; diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index c6ef3484679..f89f2eabcd0 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -8,6 +8,7 @@ #endif #if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) #include "meshUtils.h" // vformat + #endif bool in_array(uint8_t *array, int size, uint8_t lookfor) @@ -114,6 +115,42 @@ uint16_t ScanI2CTwoWire::getRegisterValue(const ScanI2CTwoWire::RegisterLocation return value; } +/// for SEN5X detection +// Note, this code needs to be called before setting the I2C bus speed +// for the screen at high speed. The speed needs to be at 100kHz, otherwise +// detection will not work +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, ...) \ case ADDR: \ logFoundDevice(__VA_ARGS__); \ @@ -568,8 +605,9 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) } break; - case ICM20948_ADDR: // same as BMX160_ADDR + case ICM20948_ADDR: // same as BMX160_ADDR and SEN5X_ADDR case ICM20948_ADDR_ALT: // same as MPU6050_ADDR + // ICM20948 Register check registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1); #ifdef HAS_ICM20948 type = ICM20948; @@ -580,14 +618,31 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) type = ICM20948; logFoundDevice("ICM20948", (uint8_t)addr.address); break; - } else if (addr.address == BMX160_ADDR) { - type = BMX160; - logFoundDevice("BMX160", (uint8_t)addr.address); - break; } else { - type = MPU6050; - logFoundDevice("MPU6050", (uint8_t)addr.address); - break; + String prod = ""; + prod = readSEN5xProductName(i2cBus, addr.address); + if (prod.startsWith("SEN55")) { + type = SEN5X; + logFoundDevice("Sensirion SEN55", addr.address); + break; + } else if (prod.startsWith("SEN54")) { + type = SEN5X; + logFoundDevice("Sensirion SEN54", addr.address); + break; + } else if (prod.startsWith("SEN50")) { + type = SEN5X; + logFoundDevice("Sensirion SEN50", addr.address); + break; + } + if (addr.address == BMX160_ADDR) { + type = BMX160; + logFoundDevice("BMX160", (uint8_t)addr.address); + break; + } else { + type = MPU6050; + logFoundDevice("MPU6050", (uint8_t)addr.address); + break; + } } break; diff --git a/src/detect/reClockI2C.cpp b/src/detect/reClockI2C.cpp new file mode 100644 index 00000000000..5315bf3eca1 --- /dev/null +++ b/src/detect/reClockI2C.cpp @@ -0,0 +1,31 @@ +#include "reClockI2C.h" +#include "ScanI2CTwoWire.h" + +uint32_t reClockI2C(uint32_t desiredClock, TwoWire *i2cBus, bool force) { + + uint32_t currentClock = 0; + + /* See https://github.com/arduino/Arduino/issues/11457 + Currently, only ESP32 can getClock() + While all cores can setClock() + https://github.com/sandeepmistry/arduino-nRF5/blob/master/libraries/Wire/Wire.h#L50 + https://github.com/earlephilhower/arduino-pico/blob/master/libraries/Wire/src/Wire.h#L60 + https://github.com/stm32duino/Arduino_Core_STM32/blob/main/libraries/Wire/src/Wire.h#L103 + For cases when I2C speed is different to the ones defined by sensors (see defines in sensor classes) + we need to reclock I2C and set it back to the previous desired speed. + Only for cases where we can know OR predefine the speed, we can do this. + */ + +// TODO add getClock function or return a predefined clock speed per variant? +#ifdef CAN_RECLOCK_I2C + currentClock = i2cBus->getClock(); +#endif + + if ((currentClock != desiredClock) || force){ + LOG_DEBUG("Changing I2C clock to %u", desiredClock); + i2cBus->setClock(desiredClock); + } + + return currentClock; +} + diff --git a/src/detect/reClockI2C.h b/src/detect/reClockI2C.h index 689e88d6f19..c74ccffc557 100644 --- a/src/detect/reClockI2C.h +++ b/src/detect/reClockI2C.h @@ -1,41 +1,11 @@ -#ifdef CAN_RECLOCK_I2C -#include "ScanI2CTwoWire.h" - -uint32_t reClockI2C(uint32_t desiredClock, TwoWire *i2cBus) -{ - uint32_t currentClock; +#ifndef RECLOCK_I2C_ +#define RECLOCK_I2C_ - /* See https://github.com/arduino/Arduino/issues/11457 - Currently, only ESP32 can getClock() - While all cores can setClock() - https://github.com/sandeepmistry/arduino-nRF5/blob/master/libraries/Wire/Wire.h#L50 - https://github.com/earlephilhower/arduino-pico/blob/master/libraries/Wire/src/Wire.h#L60 - https://github.com/stm32duino/Arduino_Core_STM32/blob/main/libraries/Wire/src/Wire.h#L103 - For cases when I2C speed is different to the ones defined by sensors (see defines in sensor classes) - we need to reclock I2C and set it back to the previous desired speed. - Only for cases where we can know OR predefine the speed, we can do this. - */ +#include "ScanI2CTwoWire.h" +#include +#include -#ifdef ARCH_ESP32 - currentClock = i2cBus->getClock(); -#elif defined(ARCH_NRF52) - // TODO add getClock function or return a predefined clock speed per variant? - return 0; -#elif defined(ARCH_RP2040) - // TODO add getClock function or return a predefined clock speed per variant - return 0; -#elif defined(ARCH_STM32WL) - // TODO add getClock function or return a predefined clock speed per variant - return 0; -#else - return 0; -#endif +uint32_t reClockI2C(uint32_t desiredClock, TwoWire *i2cBus, bool force); - if (currentClock != desiredClock) { - LOG_DEBUG("Changing I2C clock to %u", desiredClock); - i2cBus->setClock(desiredClock); - } - return currentClock; -} #endif diff --git a/src/main.cpp b/src/main.cpp index 68eda2d0ddd..dd75f41387b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -713,7 +713,6 @@ 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); - #endif #ifdef HAS_SDCARD diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp index 01f5da2c6c8..d34ff593c51 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.cpp +++ b/src/modules/Telemetry/AirQualityTelemetry.cpp @@ -3,24 +3,29 @@ #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR #include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "AirQualityTelemetry.h" #include "Default.h" +#include "AirQualityTelemetry.h" #include "MeshService.h" #include "NodeDB.h" #include "PowerFSM.h" #include "RTC.h" #include "Router.h" -#include "Sensor/AddI2CSensorTemplate.h" #include "UnitConversions.h" -#include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" #include "graphics/images.h" +#include "graphics/ScreenFonts.h" #include "main.h" #include "sleep.h" #include + // Sensors +#include "Sensor/AddI2CSensorTemplate.h" #include "Sensor/PMSA003ISensor.h" +#include "Sensor/SEN5XSensor.h" +#if __has_include() +#include "Sensor/SCD4XSensor.h" +#endif void AirQualityTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) { @@ -42,6 +47,10 @@ void AirQualityTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) // order by priority of metrics/values (low top, high bottom) addSensor(i2cScanner, ScanI2C::DeviceType::PMSA003I); + addSensor(i2cScanner, ScanI2C::DeviceType::SEN5X); +#if __has_include() + addSensor(i2cScanner, ScanI2C::DeviceType::SCD4X); +#endif } int32_t AirQualityTelemetryModule::runOnce() @@ -56,7 +65,7 @@ int32_t AirQualityTelemetryModule::runOnce() uint32_t result = UINT32_MAX; - if (!(moduleConfig.telemetry.air_quality_enabled || moduleConfig.telemetry.air_quality_screen_enabled || + if (!(moduleConfig.telemetry.air_quality_enabled || moduleConfig.telemetry.air_quality_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 return disable(); @@ -85,23 +94,37 @@ int32_t AirQualityTelemetryModule::runOnce() } // Wake up the sensors that need it - LOG_INFO("Waking up sensors"); + LOG_INFO("Waking up sensors..."); for (TelemetrySensor *sensor : sensors) { - if (!sensor->isActive()) { - return sensor->wakeUp(); + if (!sensor->canSleep()) { + LOG_DEBUG("%s sensor doesn't have sleep feature. Skipping", sensor->sensorName); + } else if (((lastSentToMesh == 0) || + !Throttle::isWithinTimespanMs(lastSentToMesh - sensor->wakeUpTimeMs(), 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()) { + if (!sensor->isActive()) { + LOG_DEBUG("Waking up: %s", sensor->sensorName); + return sensor->wakeUp(); + } else { + int32_t pendingForReadyMs = sensor->pendingForReadyMs(); + LOG_DEBUG("%s. Pending for ready %ums", sensor->sensorName, pendingForReadyMs); + if (pendingForReadyMs) { + return pendingForReadyMs; + } + } } } if (((lastSentToMesh == 0) || - !Throttle::isWithinTimespanMs(lastSentToMesh, Default::getConfiguredOrDefaultMsScaled( - moduleConfig.telemetry.air_quality_interval, - default_telemetry_broadcast_interval_secs, numOnlineNodes))) && + !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())) { + (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); @@ -109,9 +132,18 @@ int32_t AirQualityTelemetryModule::runOnce() } // Send to sleep sensors that consume power - LOG_INFO("Sending sensors to sleep"); + LOG_DEBUG("Sending sensors to sleep"); for (TelemetrySensor *sensor : sensors) { - sensor->sleep(); + if (sensor->isActive() && sensor->canSleep()) { + if (sensor->wakeUpTimeMs() < Default::getConfiguredOrDefaultMsScaled( + moduleConfig.telemetry.air_quality_interval, + default_telemetry_broadcast_interval_secs, numOnlineNodes)) { + LOG_DEBUG("Disabling %s until next period", sensor->sensorName); + sensor->sleep(); + } else { + LOG_DEBUG("Sensor stays enabled due to warm up period"); + } + } } } return min(sendToPhoneIntervalMs, result); @@ -158,8 +190,7 @@ void AirQualityTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSta 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; + bool hasAny = m.has_sen5xdata || m.has_pmsa003idata || m.has_scd4xdata; if (!hasAny) { display->drawString(x, currentY, "No Telemetry"); @@ -180,12 +211,25 @@ void AirQualityTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSta // === Collect sensor readings as label strings (no icons) === std::vector entries; - if (m.has_pm10_standard) - entries.push_back("PM1: " + 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: " + String(m.pm100_standard) + "ug/m3"); + if (m.has_pmsa003idata) { + if (m.pmsa003idata.has_pm10_standard) + entries.push_back("PM1: " + String(m.pmsa003idata.pm10_standard) + "ug/m3"); + if (m.pmsa003idata.has_pm25_standard) + entries.push_back("PM2.5: " + String(m.pmsa003idata.pm25_standard) + "ug/m3"); + if (m.pmsa003idata.has_pm100_standard) + entries.push_back("PM10: " + String(m.pmsa003idata.pm100_standard) + "ug/m3"); + } + if (m.has_sen5xdata) { + if (m.sen5xdata.has_pm10_standard) + entries.push_back("PM1: " + String(m.sen5xdata.pm10_standard) + "ug/m3"); + if (m.sen5xdata.has_pm25_standard) + entries.push_back("PM2.5: " + String(m.sen5xdata.pm25_standard) + "ug/m3"); + if (m.sen5xdata.has_pm100_standard) + entries.push_back("PM10: " + String(m.sen5xdata.pm100_standard) + "ug/m3"); + } + if (m.has_scd4xdata) + entries.push_back("CO2: " + String(m.scd4xdata.co2) + "ppm"); + // === Show first available metric on top-right of first line === if (!entries.empty()) { @@ -221,13 +265,13 @@ bool AirQualityTelemetryModule::handleReceivedProtobuf(const meshtastic_MeshPack #if defined(DEBUG_PORT) && !defined(DEBUG_MUTE) const char *sender = getSenderShortName(mp); - LOG_INFO("(Received from %s): pm10_standard=%i, pm25_standard=%i, pm100_standard=%i", sender, - t->variant.air_quality_metrics.pm10_standard, t->variant.air_quality_metrics.pm25_standard, - t->variant.air_quality_metrics.pm100_standard); + // LOG_INFO("(Received from %s): pm10_standard=%i, pm25_standard=%i, pm100_standard=%i", sender, + // t->variant.air_quality_metrics.pm10_standard, t->variant.air_quality_metrics.pm25_standard, + // t->variant.air_quality_metrics.pm100_standard); - 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.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 // release previous packet before occupying a new spot if (lastMeasurementPacket != nullptr) @@ -241,17 +285,20 @@ bool AirQualityTelemetryModule::handleReceivedProtobuf(const meshtastic_MeshPack bool AirQualityTelemetryModule::getAirQualityTelemetry(meshtastic_Telemetry *m) { - bool valid = true; + // Note: this is different to the case in EnvironmentTelemetryModule + // There, if any sensor fails to read - valid = false. + bool valid = false; bool hasSensor = false; m->time = getTime(); m->which_variant = meshtastic_Telemetry_air_quality_metrics_tag; m->variant.air_quality_metrics = meshtastic_AirQualityMetrics_init_zero; - // TODO - Should we check for sensor state here? - // If a sensor is sleeping, we should know and check to wake it up + bool sensor_get = false; for (TelemetrySensor *sensor : sensors) { - LOG_INFO("Reading AQ sensors"); - valid = valid && sensor->getMetrics(m); + LOG_DEBUG("Reading %s", sensor->sensorName); + // Note - this function doesn't get properly called if within a conditional + sensor_get = sensor->getMetrics(m); + valid = valid || sensor_get; hasSensor = true; } @@ -291,12 +338,24 @@ bool AirQualityTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) meshtastic_Telemetry m = meshtastic_Telemetry_init_zero; m.which_variant = meshtastic_Telemetry_air_quality_metrics_tag; m.time = getTime(); + if (getAirQualityTelemetry(&m)) { - 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 hasAnyPM = m.variant.air_quality_metrics.has_sen5xdata || m.variant.air_quality_metrics.has_pmsa003idata; + + // if (hasAnyPM) { + // LOG_INFO("Send: pm10_standard=%u, pm25_standard=%u, pm100_standard=%u", \ + // m.variant.air_quality_metrics.sen5xdata.pm10_standard, m.variant.air_quality_metrics.sen5xdata.pm25_standard, \ + // m.variant.air_quality_metrics.sen5xdata.pm100_standard); + // } + + // bool hasAnyCO2 = m.variant.air_quality_metrics.has_scd4xdata; + + // if (hasAnyCO2) { + // LOG_INFO("Send: co2=%i, co2_t=%f, co2_rh=%f", + // m.variant.air_quality_metrics.scd4xdata.co2, m.variant.air_quality_metrics.scd4xdata.co2_temperature, + // m.variant.air_quality_metrics.scd4xdata.co2_humidity); + // } meshtastic_MeshPacket *p = allocDataProtobuf(m); p->to = dest; @@ -331,6 +390,20 @@ bool AirQualityTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) LOG_DEBUG("Start next execution in 5s, then sleep"); setIntervalFromNow(FIVE_SECONDS_MS); } + + 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; } @@ -338,8 +411,8 @@ bool AirQualityTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) } AdminMessageHandleResult AirQualityTelemetryModule::handleAdminMessageForModule(const meshtastic_MeshPacket &mp, - meshtastic_AdminMessage *request, - meshtastic_AdminMessage *response) + meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) { AdminMessageHandleResult result = AdminMessageHandleResult::NOT_HANDLED; diff --git a/src/modules/Telemetry/Sensor/AddI2CSensorTemplate.h b/src/modules/Telemetry/Sensor/AddI2CSensorTemplate.h index 37d909d71f1..01aacc6741b 100644 --- a/src/modules/Telemetry/Sensor/AddI2CSensorTemplate.h +++ b/src/modules/Telemetry/Sensor/AddI2CSensorTemplate.h @@ -1,10 +1,10 @@ #if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR || !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR +#include #include "TelemetrySensor.h" #include "detect/ScanI2C.h" #include "detect/ScanI2CTwoWire.h" #include -#include static std::forward_list sensors; diff --git a/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp b/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp index 2225a4d8728..7aae3f2661d 100644 --- a/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp +++ b/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp @@ -2,14 +2,14 @@ #if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR -#include "../detect/reClockI2C.h" #include "../mesh/generated/meshtastic/telemetry.pb.h" #include "PMSA003ISensor.h" -#include "TelemetrySensor.h" - -#include +#include "../detect/reClockI2C.h" -PMSA003ISensor::PMSA003ISensor() : TelemetrySensor(meshtastic_TelemetrySensorType_PMSA003I, "PMSA003I") {} +PMSA003ISensor::PMSA003ISensor() + : TelemetrySensor(meshtastic_TelemetrySensorType_PMSA003I, "PMSA003I") +{ +} bool PMSA003ISensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) { @@ -21,26 +21,29 @@ bool PMSA003ISensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) _bus = bus; _address = dev->address.address; -#if defined(PMSA003I_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) - uint32_t currentClock = reClockI2C(PMSA003I_I2C_CLOCK_SPEED, _bus); - if (!currentClock) { - LOG_WARN("PMSA003I can't be used at this clock speed"); - return false; - } -#endif +#ifdef PMSA003I_I2C_CLOCK_SPEED +#ifdef CAN_RECLOCK_I2C + uint32_t currentClock = reClockI2C(PMSA003I_I2C_CLOCK_SPEED, _bus, false); +#elif !HAS_SCREEN + reClockI2C(PMSA003I_I2C_CLOCK_SPEED, _bus, true); +#else + LOG_WARN("%s can't be used at this clock speed, with a screen", sensorName); + return false; +#endif /* CAN_RECLOCK_I2C */ +#endif /* PMSA003I_I2C_CLOCK_SPEED */ _bus->beginTransmission(_address); if (_bus->endTransmission() != 0) { - LOG_WARN("PMSA003I not found on I2C at 0x12"); + LOG_WARN("%s not found on I2C at 0x12", sensorName); return false; } #if defined(PMSA003I_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) - reClockI2C(currentClock, _bus); + reClockI2C(currentClock, _bus, false); #endif status = 1; - LOG_INFO("PMSA003I Enabled"); + LOG_INFO("%s Enabled", sensorName); initI2CSensor(); return true; @@ -48,35 +51,44 @@ bool PMSA003ISensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) bool PMSA003ISensor::getMetrics(meshtastic_Telemetry *measurement) { - if (!isActive()) { - LOG_WARN("PMSA003I is not active"); + if(!isActive()){ + LOG_WARN("Can't get metrics. %s is not active", sensorName); return false; } -#if defined(PMSA003I_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) - uint32_t currentClock = reClockI2C(PMSA003I_I2C_CLOCK_SPEED, _bus); -#endif +#ifdef PMSA003I_I2C_CLOCK_SPEED +#ifdef CAN_RECLOCK_I2C + uint32_t currentClock = reClockI2C(PMSA003I_I2C_CLOCK_SPEED, _bus, false); +#elif !HAS_SCREEN + reClockI2C(PMSA003I_I2C_CLOCK_SPEED, _bus, true); +#else + LOG_WARN("%s can't be used at this clock speed, with a screen", sensorName); + return false; +#endif /* CAN_RECLOCK_I2C */ +#endif /* PMSA003I_I2C_CLOCK_SPEED */ _bus->requestFrom(_address, PMSA003I_FRAME_LENGTH); if (_bus->available() < PMSA003I_FRAME_LENGTH) { - LOG_WARN("PMSA003I read failed: incomplete data (%d bytes)", _bus->available()); + LOG_WARN("%s read failed: incomplete data (%d bytes)", sensorName, _bus->available()); return false; } -#if defined(PMSA003I_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) - reClockI2C(currentClock, _bus); -#endif - for (uint8_t i = 0; i < PMSA003I_FRAME_LENGTH; i++) { buffer[i] = _bus->read(); } +#if defined(PMSA003I_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif + if (buffer[0] != 0x42 || buffer[1] != 0x4D) { - LOG_WARN("PMSA003I frame header invalid: 0x%02X 0x%02X", buffer[0], buffer[1]); + LOG_WARN("%s frame header invalid: 0x%02X 0x%02X", sensorName, buffer[0], buffer[1]); return false; } - auto read16 = [](uint8_t *data, uint8_t idx) -> uint16_t { return (data[idx] << 8) | data[idx + 1]; }; + auto read16 = [](uint8_t *data, uint8_t idx) -> uint16_t { + return (data[idx] << 8) | data[idx + 1]; + }; computedChecksum = 0; @@ -86,47 +98,48 @@ bool PMSA003ISensor::getMetrics(meshtastic_Telemetry *measurement) receivedChecksum = read16(buffer, PMSA003I_FRAME_LENGTH - 2); if (computedChecksum != receivedChecksum) { - LOG_WARN("PMSA003I checksum failed: computed 0x%04X, received 0x%04X", computedChecksum, receivedChecksum); + LOG_WARN("%s checksum failed: computed 0x%04X, received 0x%04X", sensorName, 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_pmsa003idata = true; + measurement->variant.air_quality_metrics.pmsa003idata.has_pm10_standard = true; + measurement->variant.air_quality_metrics.pmsa003idata.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.pmsa003idata.has_pm25_standard = true; + measurement->variant.air_quality_metrics.pmsa003idata.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.pmsa003idata.has_pm100_standard = true; + measurement->variant.air_quality_metrics.pmsa003idata.pm100_standard = read16(buffer, 8); // TODO - Add admin command to remove environmental metrics to save protobuf space - measurement->variant.air_quality_metrics.has_pm10_environmental = true; - measurement->variant.air_quality_metrics.pm10_environmental = read16(buffer, 10); + measurement->variant.air_quality_metrics.pmsa003idata.has_pm10_environmental = true; + measurement->variant.air_quality_metrics.pmsa003idata.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.pmsa003idata.has_pm25_environmental = true; + measurement->variant.air_quality_metrics.pmsa003idata.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.pmsa003idata.has_pm100_environmental = true; + measurement->variant.air_quality_metrics.pmsa003idata.pm100_environmental = read16(buffer, 14); // TODO - Add admin command to remove PN to save protobuf space - measurement->variant.air_quality_metrics.has_particles_03um = true; - measurement->variant.air_quality_metrics.particles_03um = read16(buffer, 16); + measurement->variant.air_quality_metrics.pmsa003idata.has_particles_03um = true; + measurement->variant.air_quality_metrics.pmsa003idata.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.pmsa003idata.has_particles_05um = true; + measurement->variant.air_quality_metrics.pmsa003idata.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.pmsa003idata.has_particles_10um = true; + measurement->variant.air_quality_metrics.pmsa003idata.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.pmsa003idata.has_particles_25um = true; + measurement->variant.air_quality_metrics.pmsa003idata.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.pmsa003idata.has_particles_50um = true; + measurement->variant.air_quality_metrics.pmsa003idata.particles_50um = read16(buffer, 24); - measurement->variant.air_quality_metrics.has_particles_100um = true; - measurement->variant.air_quality_metrics.particles_100um = read16(buffer, 26); + measurement->variant.air_quality_metrics.pmsa003idata.has_particles_100um = true; + measurement->variant.air_quality_metrics.pmsa003idata.particles_100um = read16(buffer, 26); return true; } @@ -136,20 +149,58 @@ bool PMSA003ISensor::isActive() return state == State::ACTIVE; } +int32_t PMSA003ISensor::wakeUpTimeMs() +{ +#ifdef PMSA003I_ENABLE_PIN + return PMSA003I_WARMUP_MS; +#endif + return 0; +} + +int32_t PMSA003ISensor::pendingForReadyMs() +{ +#ifdef PMSA003I_ENABLE_PIN + + uint32_t now; + now = getTime(); + uint32_t sincePmMeasureStarted = (now - pmMeasureStarted)*1000; + LOG_DEBUG("%s: Since measure started: %ums", sensorName, sincePmMeasureStarted); + + if (sincePmMeasureStarted < PMSA003I_WARMUP_MS) { + LOG_INFO("%s: not enough time passed since starting measurement", sensorName); + return PMSA003I_WARMUP_MS - sincePmMeasureStarted; + } + return 0; + +#endif + return 0; +} + + +bool PMSA003ISensor::canSleep() { +#ifdef PMSA003I_ENABLE_PIN + return true; +#endif + return false; +} + void PMSA003ISensor::sleep() { #ifdef PMSA003I_ENABLE_PIN digitalWrite(PMSA003I_ENABLE_PIN, LOW); state = State::IDLE; + pmMeasureStarted = 0; #endif } uint32_t PMSA003ISensor::wakeUp() { #ifdef PMSA003I_ENABLE_PIN - LOG_INFO("Waking up PMSA003I"); + LOG_INFO("Waking up %s", sensorName); digitalWrite(PMSA003I_ENABLE_PIN, HIGH); state = State::ACTIVE; + pmMeasureStarted = getTime(); + return PMSA003I_WARMUP_MS; #endif // No need to wait for warmup if already active diff --git a/src/modules/Telemetry/Sensor/PMSA003ISensor.h b/src/modules/Telemetry/Sensor/PMSA003ISensor.h index 09b43d620c0..a2a0951801d 100644 --- a/src/modules/Telemetry/Sensor/PMSA003ISensor.h +++ b/src/modules/Telemetry/Sensor/PMSA003ISensor.h @@ -4,32 +4,38 @@ #include "../mesh/generated/meshtastic/telemetry.pb.h" #include "TelemetrySensor.h" +#include "RTC.h" #define PMSA003I_I2C_CLOCK_SPEED 100000 -#define PMSA003I_FRAME_LENGTH 32 +#define PMSA003I_FRAME_LENGTH 32 #define PMSA003I_WARMUP_MS 30000 class PMSA003ISensor : public TelemetrySensor { - public: - PMSA003ISensor(); - virtual bool getMetrics(meshtastic_Telemetry *measurement) override; - virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; - - virtual bool isActive() override; - virtual void sleep() override; - virtual uint32_t wakeUp() override; - - private: +private: enum class State { IDLE, ACTIVE }; State state = State::ACTIVE; uint16_t computedChecksum = 0; uint16_t receivedChecksum = 0; + uint32_t pmMeasureStarted = 0; uint8_t buffer[PMSA003I_FRAME_LENGTH]{}; - TwoWire *_bus{}; + TwoWire * _bus{}; uint8_t _address{}; + +public: + PMSA003ISensor(); + virtual bool getMetrics(meshtastic_Telemetry *measurement) override; + virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; + + virtual bool isActive() override; + virtual void sleep() override; + virtual uint32_t wakeUp() override; + virtual bool canSleep() override; + virtual int32_t wakeUpTimeMs() override; + virtual int32_t pendingForReadyMs() override; + }; #endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SCD4XSensor.cpp b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp new file mode 100644 index 00000000000..7e5b50cc22d --- /dev/null +++ b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp @@ -0,0 +1,771 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "SCD4XSensor.h" +#include "../detect/reClockI2C.h" + +#define SCD4X_NO_ERROR 0 + +SCD4XSensor::SCD4XSensor() + : TelemetrySensor(meshtastic_TelemetrySensorType_SCD4X, "SCD4X") +{ +} + +bool SCD4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) +{ + LOG_INFO("Init sensor: %s", sensorName); + + _bus = bus; + _address = dev->address.address; + +#ifdef SCD4X_I2C_CLOCK_SPEED +#ifdef CAN_RECLOCK_I2C + uint32_t currentClock = reClockI2C(SCD4X_I2C_CLOCK_SPEED, _bus, false); +#elif !HAS_SCREEN + reClockI2C(SCD4X_I2C_CLOCK_SPEED, _bus, true); +#else + LOG_WARN("%s can't be used at this clock speed, with a screen", sensorName); + return false; +#endif /* CAN_RECLOCK_I2C */ +#endif /* SCD4X_I2C_CLOCK_SPEED */ + + scd4x.begin(*_bus, _address); + + // From SCD4X library + delay(30); + + // Stop periodic measurement + if (!stopMeasurement()) { + return false; + } + + // Get sensor variant + scd4x.getSensorVariant(sensorVariant); + + if (sensorVariant == SCD4X_SENSOR_VARIANT_SCD41){ + LOG_INFO("%s: Found SCD41", sensorName); + if (!powerUp()) { + LOG_ERROR("%s: Error trying to execute powerUp()", sensorName); + return false; + } + } + + if (!getASC(ascActive)){ + LOG_ERROR("%s: Unable to check if ASC is enabled", sensorName); + return false; + } + + // Start measurement in selected power mode (low power by default) + if (!startMeasurement()){ + LOG_ERROR("%s: Couldn't start measurement", sensorName); + return false; + } + +#if defined(SCD4X_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif + + if (state == SCD4X_MEASUREMENT){ + status = 1; + } else { + status = 0; + } + + initI2CSensor(); + + return true; +} + +bool SCD4XSensor::getMetrics(meshtastic_Telemetry *measurement) +{ + + if (state != SCD4X_MEASUREMENT) { + LOG_ERROR("%s: Not in measurement mode", sensorName); + return false; + } + + uint16_t co2, error; + float temperature, humidity; + +#ifdef SCD4X_I2C_CLOCK_SPEED +#ifdef CAN_RECLOCK_I2C + uint32_t currentClock = reClockI2C(SCD4X_I2C_CLOCK_SPEED, _bus, false); +#elif !HAS_SCREEN + reClockI2C(SCD4X_I2C_CLOCK_SPEED, _bus, true); +#else + LOG_WARN("%s can't be used at this clock speed, with a screen", sensorName); + return false; +#endif /* CAN_RECLOCK_I2C */ +#endif /* SCD4X_I2C_CLOCK_SPEED */ + + bool dataReady; + error = scd4x.getDataReadyStatus(dataReady); + if (!dataReady) { + LOG_ERROR("SCD4X: Data is not ready"); + return false; + } + + error = scd4x.readMeasurement(co2, temperature, humidity); + +#if defined(SCD4X_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif + + LOG_DEBUG("%s readings: %u ppm, %.2f degC, %.2f %rh", sensorName, co2, temperature, humidity); + if (error != SCD4X_NO_ERROR) { + LOG_DEBUG("%s: Error while getting measurements: %u", sensorName, error); + if (co2 == 0) { + LOG_ERROR("%s: Skipping invalid measurement.", sensorName); + } + return false; + } else { + measurement->variant.air_quality_metrics.has_scd4xdata = true; + measurement->variant.air_quality_metrics.scd4xdata.has_co2_temperature = true; + measurement->variant.air_quality_metrics.scd4xdata.has_co2_humidity = true; + measurement->variant.air_quality_metrics.scd4xdata.has_co2 = true; + measurement->variant.air_quality_metrics.scd4xdata.co2_temperature = temperature; + measurement->variant.air_quality_metrics.scd4xdata.co2_humidity = humidity; + measurement->variant.air_quality_metrics.scd4xdata.co2 = co2; + return true; + } +} + +// TODO +// Make all functions change I2C clock + +/** +* @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("%s: Issuing FRC. Ensure device has been working at least 3 minutes in stable target environment", sensorName); + + if (!stopMeasurement()) { + return false; + } + + LOG_INFO("%s: Target CO2: %u ppm", sensorName, targetCO2); + error = scd4x.performForcedRecalibration((uint16_t)targetCO2, frcCorr); + + // SCD4X Sensirion datasheet + delay(400); + + if (error != SCD4X_NO_ERROR){ + LOG_ERROR("%s: Unable to perform forced recalibration.", sensorName); + return false; + } + + if (frcCorr == 0xFFFF) { + LOG_ERROR("%s: Error while performing forced recalibration.", sensorName); + return false; + } + + LOG_INFO("%s: FRC Correction successful. Correction output: %u", sensorName, (uint16_t)(frcCorr-0x8000)); + + return true; +} + +bool SCD4XSensor::startMeasurement() { + uint16_t error; + + if (state == SCD4X_MEASUREMENT){ + LOG_DEBUG("%s: Already in measurement mode", sensorName); + return true; + } + + if (lowPower) { + error = scd4x.startLowPowerPeriodicMeasurement(); + } else { + error = scd4x.startPeriodicMeasurement(); + } + + if (error == SCD4X_NO_ERROR) { + LOG_INFO("%s: Started measurement mode", sensorName); + if (lowPower) { + LOG_INFO("%s: Low power mode", sensorName); + } else { + LOG_INFO("%s: Normal power mode", sensorName); + } + + state = SCD4X_MEASUREMENT; + return true; + } else { + LOG_ERROR("%s: Couldn't start measurement mode", sensorName); + return false; + } +} + +bool SCD4XSensor::stopMeasurement(){ + uint16_t error; + + error = scd4x.stopPeriodicMeasurement(); + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to set idle mode on SCD4X.", sensorName); + return false; + } + + state = SCD4X_IDLE; + co2MeasureStarted = 0; + return true; +} + +bool SCD4XSensor::setPowerMode(bool _lowPower) { + lowPower = _lowPower; + + if (!stopMeasurement()) { + return false; + } + + if (lowPower) { + LOG_DEBUG("%s: Set low power mode", sensorName); + } else { + LOG_DEBUG("%s: Set normal power mode", sensorName); + } + + 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("%s: Getting ASC", sensorName); + + if (!stopMeasurement()) { + return false; + } + error = scd4x.getAutomaticSelfCalibrationEnabled(_ascActive); + + if (error != SCD4X_NO_ERROR){ + LOG_ERROR("%s: Unable to send command.", sensorName); + return false; + } + + if (_ascActive){ + LOG_INFO("%s: ASC is enabled", sensorName); + } else { + LOG_INFO("%s: FRC is enabled", sensorName); + } + + 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("%s: Enabling ASC", sensorName); + } else { + LOG_INFO("%s: Disabling ASC", sensorName); + } + + if (!stopMeasurement()) { + return false; + } + + error = scd4x.setAutomaticSelfCalibrationEnabled((uint16_t)ascEnabled); + + if (error != SCD4X_NO_ERROR){ + LOG_ERROR("%s: Unable to send command.", sensorName); + return false; + } + + error = scd4x.persistSettings(); + if (error != SCD4X_NO_ERROR){ + LOG_ERROR("%s: Unable to make settings persistent.", sensorName); + return false; + } + + if (!getASC(ascActive)){ + LOG_ERROR("%s: Unable to check if ASC is enabled", sensorName); + return false; + } + + if (ascActive){ + LOG_INFO("%s: ASC is enabled", sensorName); + } else { + LOG_INFO("%s: ASC is disabled", sensorName); + } + + 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("%s: Setting ASC baseline to: %u", sensorName, targetCO2); + + getASC(ascActive); + if (!ascActive){ + LOG_ERROR("%s: Can't set ASC baseline. ASC is not active", sensorName); + return false; + } + + if (!stopMeasurement()) { + return false; + } + + error = scd4x.setAutomaticSelfCalibrationTarget((uint16_t)targetCO2); + + if (error != SCD4X_NO_ERROR){ + LOG_ERROR("%s: Unable to send command.", sensorName); + return false; + } + + error = scd4x.persistSettings(); + if (error != SCD4X_NO_ERROR){ + LOG_ERROR("%s: Unable to make settings persistent.", sensorName); + return false; + } + + LOG_INFO("%s: Setting ASC baseline successful", sensorName); + + 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("%s: Setting reference temperature at: %.2f", sensorName, tempReference); + + error = scd4x.getDataReadyStatus(dataReady); + if (!dataReady) { + LOG_ERROR("%s: Data is not ready", sensorName); + return false; + } + + error = scd4x.readMeasurement(co2, temperature, humidity); + if (error != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Unable to read current temperature. Error code: %u", sensorName, error); + return false; + } + + LOG_INFO("%s: Current sensor temperature: %.2f", sensorName, temperature); + + if (!stopMeasurement()) { + return false; + } + + error = scd4x.getTemperatureOffset(prevTempOffset); + + if (error != SCD4X_NO_ERROR){ + LOG_ERROR("%s: Unable to get temperature offset. Error code: %u", sensorName, error); + return false; + } + LOG_INFO("%s: Current sensor temperature offset: %.2f", sensorName, prevTempOffset); + + tempOffset = temperature - tempReference + prevTempOffset; + + LOG_INFO("%s: Setting temperature offset: %.2f", sensorName, tempOffset); + error = scd4x.setTemperatureOffset(tempOffset); + if (error != SCD4X_NO_ERROR){ + LOG_ERROR("%s: Unable to set temperature offset. Error code: %u", sensorName, error); + return false; + } + + error = scd4x.persistSettings(); + if (error != SCD4X_NO_ERROR){ + LOG_ERROR("%s: Unable to make settings persistent. Error code: %u", sensorName, error); + return false; + } + + scd4x.getTemperatureOffset(updatedTempOffset); + LOG_INFO("%s: Updated sensor temperature offset: %.2f", sensorName, 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("%s: Requesting sensor altitude", sensorName); + + if (!stopMeasurement()) { + return false; + } + + error = scd4x.getSensorAltitude(altitude); + + if (error != SCD4X_NO_ERROR){ + LOG_ERROR("%s: Unable to get altitude. Error code: %u", sensorName, error); + return false; + } + LOG_INFO("%s: Sensor altitude: %u", sensorName, 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("%s: Requesting sensor ambient pressure", sensorName); + + error = scd4x.getAmbientPressure(ambientPressure); + + if (error != SCD4X_NO_ERROR){ + LOG_ERROR("%s: Unable to get altitude. Error code: %u", sensorName, error); + return false; + } + LOG_INFO("%s: Sensor ambient pressure: %u", sensorName, 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("%s: Unable to set altitude. Error code: %u", sensorName, error); + return false; + } + + error = scd4x.persistSettings(); + if (error != SCD4X_NO_ERROR){ + LOG_ERROR("%s: Unable to make settings persistent. Error code: %u", sensorName, 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("%s: Unable to set altitude. Error code: %u", sensorName, 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("%s: Unable to make settings persistent. Error code: %u", sensorName, 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("%s: Requesting factory reset", sensorName); + + if (!stopMeasurement()) { + return false; + } + + error = scd4x.performFactoryReset(); + + if (error != SCD4X_NO_ERROR){ + LOG_ERROR("%s: Unable to do factory reset. Error code: %u", sensorName, error); + return false; + } + + LOG_INFO("%s: Factory reset successful", sensorName); + + 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::powerDown() { + LOG_INFO("%s: Trying to send sensor to sleep", sensorName); + + 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("%s: Error trying to execute sleep()", sensorName); + return false; + } + state = SCD4X_OFF; + return true; +} + +/** +* @brief Wake up sensor from sleep mode to idle mode (powerUp) +* +* 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::powerUp(){ + LOG_INFO("%s: Waking up", sensorName); + + if (scd4x.wakeUp() != SCD4X_NO_ERROR) { + LOG_ERROR("%s: Error trying to execute wakeUp()", sensorName); + return false; + } + state = SCD4X_IDLE; + return true; +} + +/** +* @brief Check if sensor is in measurement mode +*/ +bool SCD4XSensor::isActive(){ + return state == SCD4X_MEASUREMENT; +} + +/** +* @brief Start measurement mode +*/ +uint32_t SCD4XSensor::wakeUp(){ + if (startMeasurement()) { + co2MeasureStarted = getTime(); + return SCD4X_WARMUP_MS; + } + return 0; +} + +/** +* @brief Stop measurement mode +*/ +void SCD4XSensor::sleep(){ + stopMeasurement(); +} + +/** +* @brief Can sleep function +* +* Power consumption is very low on lowPower mode, modify this function if +* you still want to override this behaviour. Otherwise, sleep is disabled +* routinely in low power mode +*/ +bool SCD4XSensor::canSleep(){ + return lowPower ? false : true; +} + +int32_t SCD4XSensor::wakeUpTimeMs(){ + return SCD4X_WARMUP_MS; +} + +int32_t SCD4XSensor::pendingForReadyMs() +{ + uint32_t now; + now = getTime(); + uint32_t sinceCO2MeasureStarted = (now - co2MeasureStarted)*1000; + LOG_DEBUG("%s: Since measure started: %ums", sensorName, sinceCO2MeasureStarted); + + if (sinceCO2MeasureStarted < SCD4X_WARMUP_MS) { + LOG_INFO("%s: not enough time passed since starting measurement", sensorName); + return SCD4X_WARMUP_MS - sinceCO2MeasureStarted; + } + return 0; +} + +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("%s: Requested factory reset", sensorName); + 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("%s: Request for FRC", sensorName); + 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("%s: target CO2 not provided", sensorName); + result = AdminMessageHandleResult::NOT_HANDLED; + break; + } + } else { + LOG_DEBUG("%s: Request for ASC", sensorName); + if (request->sensor_config.scd4x_config.has_set_target_co2_conc) { + LOG_DEBUG("%s: Request has target CO2", sensorName); + // TODO - Remove? see setASCBaseline function + this->setASCBaseline(request->sensor_config.scd4x_config.set_target_co2_conc); + } else { + LOG_DEBUG("%s: Request doesn't have target CO2", sensorName); + } + } + } + + // 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 \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SCD4XSensor.h b/src/modules/Telemetry/Sensor/SCD4XSensor.h new file mode 100644 index 00000000000..2542e408bdd --- /dev/null +++ b/src/modules/Telemetry/Sensor/SCD4XSensor.h @@ -0,0 +1,63 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "TelemetrySensor.h" +#include +#include "RTC.h" + +// Max speed 400kHz +#define SCD4X_I2C_CLOCK_SPEED 100000 +#define SCD4X_WARMUP_MS 5000 + +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); + bool factoryReset(); + bool setPowerMode(bool _lowPower); + bool startMeasurement(); + bool stopMeasurement(); + + uint16_t ascActive = 1; + // low power measurement mode (on sensirion side). Disables sleep mode + // Improvement and testing needed for timings + bool lowPower = true; + uint32_t co2MeasureStarted = 0; + + public: + SCD4XSensor(); + virtual bool getMetrics(meshtastic_Telemetry *measurement) override; + virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; + + enum SCD4XState { SCD4X_OFF, SCD4X_IDLE, SCD4X_MEASUREMENT }; + SCD4XState state = SCD4X_OFF; + SCD4xSensorVariant sensorVariant{}; + + virtual bool isActive() override; + + virtual void sleep() override; // Stops measurement (measurement -> idle) + virtual uint32_t wakeUp() override; // Starts measurement (idle -> measurement) + bool powerDown(); // Powers down sensor (idle -> power-off) + bool powerUp(); // Powers the sensor (power-off -> idle) + virtual bool canSleep() override; + virtual int32_t wakeUpTimeMs() override; + virtual int32_t pendingForReadyMs() override; + AdminMessageHandleResult handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) override; +}; + +#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SEN5XSensor.cpp b/src/modules/Telemetry/Sensor/SEN5XSensor.cpp new file mode 100644 index 00000000000..d5d23a98a3a --- /dev/null +++ b/src/modules/Telemetry/Sensor/SEN5XSensor.cpp @@ -0,0 +1,1011 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "SEN5XSensor.h" +#include "../detect/reClockI2C.h" +#include "FSCommon.h" +#include "SPILock.h" +#include "SafeFile.h" +#include +#include +#include // FLT_MAX + +SEN5XSensor::SEN5XSensor() + : TelemetrySensor(meshtastic_TelemetrySensorType_SEN5X, "SEN5X") +{ +} + +bool SEN5XSensor::getVersion() +{ + if (!sendCommand(SEN5X_GET_FIRMWARE_VERSION)){ + LOG_ERROR("SEN5X: Error sending version command"); + return false; + } + delay(20); // From Sensirion Datasheet + + uint8_t versionBuffer[12]; + size_t charNumber = readBuffer(&versionBuffer[0], 3); + if (charNumber == 0) { + LOG_ERROR("SEN5X: Error getting data ready flag value"); + return false; + } + + firmwareVer = versionBuffer[0] + (versionBuffer[1] / 10); + hardwareVer = versionBuffer[3] + (versionBuffer[4] / 10); + protocolVer = versionBuffer[5] + (versionBuffer[6] / 10); + + LOG_INFO("SEN5X Firmware Version: %0.2f", firmwareVer); + LOG_INFO("SEN5X Hardware Version: %0.2f", hardwareVer); + LOG_INFO("SEN5X Protocol Version: %0.2f", protocolVer); + + return true; +} + +bool SEN5XSensor::findModel() +{ + if (!sendCommand(SEN5X_GET_PRODUCT_NAME)) { + LOG_ERROR("SEN5X: Error asking for product name"); + return false; + } + delay(50); // From Sensirion Datasheet + + const uint8_t nameSize = 48; + uint8_t name[nameSize]; + size_t charNumber = readBuffer(&name[0], nameSize); + if (charNumber == 0) { + LOG_ERROR("SEN5X: Error getting device name"); + return false; + } + + // We only check the last character that defines the model SEN5X + switch(name[4]) + { + case 48: + model = SEN50; + LOG_INFO("SEN5X: found sensor model SEN50"); + break; + case 52: + model = SEN54; + LOG_INFO("SEN5X: found sensor model SEN54"); + break; + case 53: + model = SEN55; + LOG_INFO("SEN5X: found sensor model SEN55"); + break; + } + + return true; +} + +bool SEN5XSensor::sendCommand(uint16_t command) +{ + uint8_t nothing; + return sendCommand(command, ¬hing, 0); +} + +bool SEN5XSensor::sendCommand(uint16_t command, uint8_t* buffer, uint8_t byteNumber) +{ + // At least we need two bytes for the command + uint8_t bufferSize = 2; + + // Add space for CRC bytes (one every two bytes) + if (byteNumber > 0) bufferSize += byteNumber + (byteNumber / 2); + + uint8_t toSend[bufferSize]; + uint8_t i = 0; + toSend[i++] = static_cast((command & 0xFF00) >> 8); + toSend[i++] = static_cast((command & 0x00FF) >> 0); + + // Prepare buffer with CRC every third byte + uint8_t bi = 0; + if (byteNumber > 0) { + while (bi < byteNumber) { + toSend[i++] = buffer[bi++]; + toSend[i++] = buffer[bi++]; + uint8_t calcCRC = sen5xCRC(&buffer[bi - 2]); + toSend[i++] = calcCRC; + } + } + +#ifdef SEN5X_I2C_CLOCK_SPEED +#ifdef CAN_RECLOCK_I2C + uint32_t currentClock = reClockI2C(SEN5X_I2C_CLOCK_SPEED, _bus, false); +#elif !HAS_SCREEN + reClockI2C(SEN5X_I2C_CLOCK_SPEED, _bus, true); +#else + LOG_WARN("%s can't be used at this clock speed, with a screen", sensorName); + return false; +#endif /* CAN_RECLOCK_I2C */ +#endif /* SEN5X_I2C_CLOCK_SPEED */ + + // Transmit the data + // LOG_DEBUG("Beginning connection to SEN5X: 0x%x. Size: %u", address, bufferSize); + // Note: this is necessary to allow for long-buffers + delay(20); + _bus->beginTransmission(_address); + size_t writtenBytes = _bus->write(toSend, bufferSize); + uint8_t i2c_error = _bus->endTransmission(); + +#if defined(SEN5X_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif + + if (writtenBytes != bufferSize) { + LOG_ERROR("SEN5X: Error writting on I2C bus"); + return false; + } + + if (i2c_error != 0) { + LOG_ERROR("SEN5X: Error on I2C communication: %x", i2c_error); + return false; + } + return true; +} + +uint8_t SEN5XSensor::readBuffer(uint8_t* buffer, uint8_t byteNumber) +{ +#ifdef SEN5X_I2C_CLOCK_SPEED +#ifdef CAN_RECLOCK_I2C + uint32_t currentClock = reClockI2C(SEN5X_I2C_CLOCK_SPEED, _bus, false); +#elif !HAS_SCREEN + reClockI2C(SEN5X_I2C_CLOCK_SPEED, _bus, true); +#else + LOG_WARN("%s can't be used at this clock speed, with a screen", sensorName); + return false; +#endif /* CAN_RECLOCK_I2C */ +#endif /* SEN5X_I2C_CLOCK_SPEED */ + + size_t readBytes = _bus->requestFrom(_address, byteNumber); + if (readBytes != byteNumber) { + LOG_ERROR("SEN5X: Error reading I2C bus"); + return 0; + } + + uint8_t i = 0; + uint8_t receivedBytes = 0; + while (readBytes > 0) { + buffer[i++] = _bus->read(); // Just as a reminder: i++ returns i and after that increments. + buffer[i++] = _bus->read(); + uint8_t recvCRC = _bus->read(); + uint8_t calcCRC = sen5xCRC(&buffer[i - 2]); + if (recvCRC != calcCRC) { + LOG_ERROR("SEN5X: Checksum error while receiving msg"); + return 0; + } + readBytes -=3; + receivedBytes += 2; + } +#if defined(SEN5X_I2C_CLOCK_SPEED) && defined(CAN_RECLOCK_I2C) + reClockI2C(currentClock, _bus, false); +#endif + + return receivedBytes; +} + +uint8_t SEN5XSensor::sen5xCRC(uint8_t* buffer) +{ + // This code is based on Sensirion's own implementation https://github.com/Sensirion/arduino-core/blob/41fd02cacf307ec4945955c58ae495e56809b96c/src/SensirionCrc.cpp + uint8_t crc = 0xff; + + for (uint8_t i=0; i<2; i++){ + + crc ^= buffer[i]; + + for (uint8_t bit=8; bit>0; bit--) { + if (crc & 0x80) + crc = (crc << 1) ^ 0x31; + else + crc = (crc << 1); + } + } + + return crc; +} + +void SEN5XSensor::sleep(){ + // TODO Check this works + idle(true); +} + +bool SEN5XSensor::idle(bool checkState) +{ + // From the datasheet: + // By default, the VOC algorithm resets its state to initial + // values each time a measurement is started, + // even if the measurement was stopped only for a short + // time. So, the VOC index output value needs a long time + // until it is stable again. This can be avoided by + // restoring the previously memorized algorithm state before + // starting the measure mode + + if (checkState) { + // If the stabilisation period is not passed for SEN54 or SEN55, don't go to idle + if (model != SEN50) { + // Get VOC state before going to idle mode + vocValid = false; + if (vocStateFromSensor()) { + vocValid = vocStateValid(); + // Check if we have time, and store it + uint32_t now; // If time is RTCQualityNone, it will return zero + now = getValidTime(RTCQuality::RTCQualityDevice); + if (now) { + // Check if state is valid (non-zero) + vocTime = now; + } + } + + if (vocStateStable() && vocValid) { + saveState(); + } else { + LOG_INFO("SEN5X: Not stopping measurement, vocState is not stable yet!"); + return true; + } + } + } + + if (!oneShotMode) { + LOG_INFO("SEN5X: Not stopping measurement, continuous mode!"); + return true; + } + + // Switch to low-power based on the model + if (model == SEN50) { + if (!sendCommand(SEN5X_STOP_MEASUREMENT)) { + LOG_ERROR("SEN5X: Error stopping measurement"); + return false; + } + state = SEN5X_IDLE; + LOG_INFO("SEN5X: Stop measurement mode"); + } else { + if (!sendCommand(SEN5X_START_MEASUREMENT_RHT_GAS)) { + LOG_ERROR("SEN5X: Error switching to RHT/Gas measurement"); + return false; + } + state = SEN5X_RHTGAS_ONLY; + LOG_INFO("SEN5X: Switch to RHT/Gas only measurement mode"); + } + + delay(200); // From Sensirion Datasheet + pmMeasureStarted = 0; + return true; +} + +bool SEN5XSensor::vocStateRecent(uint32_t now){ + if (now) { + uint32_t passed = now - vocTime; //in seconds + + // Check if state is recent, less than 10 minutes (600 seconds) + if (passed < SEN5X_VOC_VALID_TIME && (now > SEN5X_VOC_VALID_DATE)) { + return true; + } + } + return false; +} + +bool SEN5XSensor::vocStateValid() { + if (!vocState[0] && !vocState[1] && !vocState[2] && !vocState[3] && + !vocState[4] && !vocState[5] && !vocState[6] && !vocState[7]) { + LOG_DEBUG("SEN5X: VOC state is all 0, invalid"); + return false; + } else { + LOG_DEBUG("SEN5X: VOC state is valid"); + return true; + } +} + +bool SEN5XSensor::vocStateToSensor() +{ + if (model == SEN50){ + return true; + } + + if (!vocStateValid()) { + LOG_INFO("SEN5X: VOC state is invalid, not sending"); + return true; + } + + if (!sendCommand(SEN5X_STOP_MEASUREMENT)) { + LOG_ERROR("SEN5X: Error stoping measurement"); + return false; + } + delay(200); // From Sensirion Datasheet + + LOG_DEBUG("SEN5X: Sending VOC state to sensor"); + LOG_DEBUG("[%u, %u, %u, %u, %u, %u, %u, %u]", + vocState[0],vocState[1], vocState[2], vocState[3], + vocState[4],vocState[5], vocState[6], vocState[7]); + + // Note: send command already takes into account the CRC + // buffer size increment needed + if (!sendCommand(SEN5X_RW_VOCS_STATE, vocState, SEN5X_VOC_STATE_BUFFER_SIZE)){ + LOG_ERROR("SEN5X: Error sending VOC's state command'"); + return false; + } + + return true; +} + +bool SEN5XSensor::vocStateFromSensor() +{ + if (model == SEN50){ + return true; + } + + LOG_INFO("SEN5X: Getting VOC state from sensor"); + // Ask VOCs state from the sensor + if (!sendCommand(SEN5X_RW_VOCS_STATE)){ + LOG_ERROR("SEN5X: Error sending VOC's state command'"); + return false; + } + + delay(20); // From Sensirion Datasheet + + // Retrieve the data + // Allocate buffer to account for CRC + // uint8_t vocBuffer[SEN5X_VOC_STATE_BUFFER_SIZE + (SEN5X_VOC_STATE_BUFFER_SIZE / 2)]; + size_t receivedNumber = readBuffer(&vocState[0], SEN5X_VOC_STATE_BUFFER_SIZE + (SEN5X_VOC_STATE_BUFFER_SIZE / 2)); + delay(20); // From Sensirion Datasheet + + if (receivedNumber == 0) { + LOG_DEBUG("SEN5X: Error getting VOC's state"); + return false; + } + + // vocState[0] = vocBuffer[0]; + // vocState[1] = vocBuffer[1]; + // vocState[2] = vocBuffer[3]; + // vocState[3] = vocBuffer[4]; + // vocState[4] = vocBuffer[6]; + // vocState[5] = vocBuffer[7]; + // vocState[6] = vocBuffer[9]; + // vocState[7] = vocBuffer[10]; + + // Print the state (if debug is on) + LOG_DEBUG("SEN5X: VOC state retrieved from sensor: [%u, %u, %u, %u, %u, %u, %u, %u]", + vocState[0],vocState[1], vocState[2], vocState[3], + vocState[4],vocState[5], vocState[6], vocState[7]); + + return true; +} + +bool SEN5XSensor::loadState() +{ +#ifdef FSCom + spiLock->lock(); + auto file = FSCom.open(sen5XStateFileName, FILE_O_READ); + bool okay = false; + if (file) { + LOG_INFO("%s state read from %s", sensorName, sen5XStateFileName); + pb_istream_t stream = {&readcb, &file, meshtastic_SEN5XState_size}; + + if (!pb_decode(&stream, &meshtastic_SEN5XState_msg, &sen5xstate)) { + LOG_ERROR("Error: can't decode protobuf %s", PB_GET_ERROR(&stream)); + } else { + lastCleaning = sen5xstate.last_cleaning_time; + lastCleaningValid = sen5xstate.last_cleaning_valid; + oneShotMode = sen5xstate.one_shot_mode; + + if (model != SEN50) { + vocTime = sen5xstate.voc_state_time; + vocValid = sen5xstate.voc_state_valid; + // Unpack state + vocState[7] = (uint8_t)(sen5xstate.voc_state_array >> 56); + vocState[6] = (uint8_t)(sen5xstate.voc_state_array >> 48); + vocState[5] = (uint8_t)(sen5xstate.voc_state_array >> 40); + vocState[4] = (uint8_t)(sen5xstate.voc_state_array >> 32); + vocState[3] = (uint8_t)(sen5xstate.voc_state_array >> 24); + vocState[2] = (uint8_t)(sen5xstate.voc_state_array >> 16); + vocState[1] = (uint8_t)(sen5xstate.voc_state_array >> 8); + vocState[0] = (uint8_t) sen5xstate.voc_state_array; + } + + // LOG_DEBUG("Loaded lastCleaning %u", lastCleaning); + // LOG_DEBUG("Loaded lastCleaningValid %u", lastCleaningValid); + // LOG_DEBUG("Loaded oneShotMode %s", oneShotMode ? "true" : "false"); + // LOG_DEBUG("Loaded vocTime %u", vocTime); + // LOG_DEBUG("Loaded [%u, %u, %u, %u, %u, %u, %u, %u]", + // vocState[7], vocState[6], vocState[5], vocState[4], vocState[3], vocState[2], vocState[1], vocState[0]); + // LOG_DEBUG("Loaded %svalid VOC state", vocValid ? "" : "in"); + + okay = true; + } + file.close(); + } else { + LOG_INFO("No %s state found (File: %s)", sensorName, sen5XStateFileName); + } + spiLock->unlock(); + return okay; +#else + LOG_ERROR("SEN5X: ERROR - Filesystem not implemented"); +#endif +} + +bool SEN5XSensor::saveState() +{ + // TODO - This should be called before a reboot for VOC index storage + // is there a way to get notified? +#ifdef FSCom + auto file = SafeFile(sen5XStateFileName); + + sen5xstate.last_cleaning_time = lastCleaning; + sen5xstate.last_cleaning_valid = lastCleaningValid; + sen5xstate.one_shot_mode = oneShotMode; + + if (model != SEN50) { + sen5xstate.has_voc_state_time = true; + sen5xstate.has_voc_state_valid = true; + sen5xstate.has_voc_state_array = true; + + sen5xstate.voc_state_time = vocTime; + sen5xstate.voc_state_valid = vocValid; + // Unpack state (8 bytes) + sen5xstate.voc_state_array = (((uint64_t) vocState[7]) << 56) | + ((uint64_t) vocState[6] << 48) | + ((uint64_t) vocState[5] << 40) | + ((uint64_t) vocState[4] << 32) | + ((uint64_t) vocState[3] << 24) | + ((uint64_t) vocState[2] << 16) | + ((uint64_t) vocState[1] << 8) | + ((uint64_t) vocState[0]); + } + + bool okay = false; + + LOG_INFO("%s: state write to %s", sensorName, sen5XStateFileName); + pb_ostream_t stream = {&writecb, static_cast(&file), meshtastic_SEN5XState_size}; + + if (!pb_encode(&stream, &meshtastic_SEN5XState_msg, &sen5xstate)) { + LOG_ERROR("Error: can't encode protobuf %s", PB_GET_ERROR(&stream)); + } else { + okay = true; + } + + okay &= file.close(); + + if (okay) + LOG_INFO("%s: state write to %s successful", sensorName, sen5XStateFileName); + + return okay; +#else + LOG_ERROR("%s: ERROR - Filesystem not implemented", sensorName); +#endif +} + +bool SEN5XSensor::isActive(){ + return state == SEN5X_MEASUREMENT || state == SEN5X_MEASUREMENT_2; +} + +uint32_t SEN5XSensor::wakeUp(){ + // uint32_t now; + // now = getValidTime(RTCQuality::RTCQualityDevice); + LOG_DEBUG("SEN5X: Waking up sensor"); + + // NOTE - No need to send it everytime if we switch to RHT/gas only mode + // // Check if state is recent, less than 10 minutes (600 seconds) + // if (vocStateRecent(now) && vocStateValid()) { + // if (!vocStateToSensor()){ + // LOG_ERROR("SEN5X: Sending VOC state to sensor failed"); + // } + // } else { + // LOG_DEBUG("SEN5X: No valid VOC state found. Ignoring"); + // } + + if (!sendCommand(SEN5X_START_MEASUREMENT)) { + LOG_ERROR("SEN5X: Error starting measurement"); + // TODO - what should this return?? Something actually on the default interval + return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS; + } + delay(50); // From Sensirion Datasheet + + // TODO - This is currently "problematic" + // If time is updated in between reads, there is no way to + // keep track of how long it has passed + pmMeasureStarted = getTime(); + state = SEN5X_MEASUREMENT; + if (state == SEN5X_MEASUREMENT) + LOG_INFO("SEN5X: Started measurement mode"); + return SEN5X_WARMUP_MS_1; +} + +bool SEN5XSensor::vocStateStable() +{ + uint32_t now; + now = getTime(); + uint32_t sinceFirstMeasureStarted = (now - rhtGasMeasureStarted); + LOG_DEBUG("sinceFirstMeasureStarted: %us", sinceFirstMeasureStarted); + return sinceFirstMeasureStarted > SEN5X_VOC_STATE_WARMUP_S; +} + +bool SEN5XSensor::startCleaning() +{ + // Note: we only should enter here if we have a valid RTC with at least + // RTCQuality::RTCQualityDevice + state = SEN5X_CLEANING; + + // Note that cleaning command can only be run when the sensor is in measurement mode + if (!sendCommand(SEN5X_START_MEASUREMENT)) { + LOG_ERROR("SEN5X: Error starting measurment mode"); + return false; + } + delay(50); // From Sensirion Datasheet + + if (!sendCommand(SEN5X_START_FAN_CLEANING)) { + LOG_ERROR("SEN5X: Error starting fan cleaning"); + return false; + } + delay(20); // From Sensirion Datasheet + + // This message will be always printed so the user knows the device it's not hung + LOG_INFO("SEN5X: Started fan cleaning it will take 10 seconds..."); + + uint16_t started = millis(); + while (millis() - started < 10500) { + // Serial.print("."); + delay(500); + } + LOG_INFO("SEN5X: Cleaning done!!"); + + // Save timestamp in flash so we know when a week has passed + uint32_t now; + now = getValidTime(RTCQuality::RTCQualityDevice); + // If time is not RTCQualityNone, it will return non-zero + lastCleaning = now; + lastCleaningValid = true; + saveState(); + + idle(); + return true; +} + +bool SEN5XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) +{ + state = SEN5X_NOT_DETECTED; + LOG_INFO("Init sensor: %s", sensorName); + + _bus = bus; + _address = dev->address.address; + + delay(50); // without this there is an error on the deviceReset function + + if (!sendCommand(SEN5X_RESET)) { + LOG_ERROR("SEN5X: Error reseting device"); + return false; + } + delay(200); // From Sensirion Datasheet + + if (!findModel()) { + LOG_ERROR("SEN5X: error finding sensor model"); + return false; + } + + // Check the firmware version + if (!getVersion()) return false; + if (firmwareVer < 2) { + LOG_ERROR("SEN5X: error firmware is too old and will not work with this implementation"); + return false; + } + delay(200); // From Sensirion Datasheet + + // Detection succeeded + state = SEN5X_IDLE; + status = 1; + + // Load state + loadState(); + + // Check if it is time to do a cleaning + uint32_t now; + int32_t passed; + now = getValidTime(RTCQuality::RTCQualityDevice); + + // If time is not RTCQualityNone, it will return non-zero + if (now) { + if (lastCleaningValid) { + + passed = now - lastCleaning; // in seconds + + if (passed > ONE_WEEK_IN_SECONDS && (now > SEN5X_VOC_VALID_DATE)) { + // If current date greater than 01/01/2018 (validity check) + LOG_INFO("SEN5X: More than a week (%us) since last cleaning in epoch (%us). Trigger, cleaning...", passed, lastCleaning); + startCleaning(); + } else { + LOG_INFO("SEN5X: Cleaning not needed (%ds passed). Last cleaning date (in epoch): %us", passed, lastCleaning); + } + } else { + // We assume the device has just been updated or it is new, + // so no need to trigger a cleaning. + // Just save the timestamp to do a cleaning one week from now. + // Otherwise, we will never trigger cleaning in some cases + lastCleaning = now; + lastCleaningValid = true; + LOG_INFO("SEN5X: No valid last cleaning date found, saving it now: %us", lastCleaning); + saveState(); + } + + if (model != SEN50) { + if (!vocValid) { + LOG_INFO("SEN5X: No valid VOC's state found"); + } else { + // Check if state is recent + if (vocStateRecent(now)) { + // If current date greater than 01/01/2018 (validity check) + // Send it to the sensor + LOG_INFO("SEN5X: VOC state is valid and recent"); + vocStateToSensor(); + } else { + LOG_INFO("SEN5X: VOC state is too old or date is invalid"); + LOG_DEBUG("SEN5X: vocTime %u, Passed %u, and now %u", vocTime, passed, now); + } + } + } + } else { + // TODO - Should this actually ignore? We could end up never cleaning... + LOG_INFO("SEN5X: Not enough RTCQuality, ignoring saved state. Trying again later"); + } + + idle(false); + rhtGasMeasureStarted = now; + + initI2CSensor(); + return true; +} + +bool SEN5XSensor::readValues() +{ + if (!sendCommand(SEN5X_READ_VALUES)){ + LOG_ERROR("SEN5X: Error sending read command"); + return false; + } + LOG_DEBUG("SEN5X: Reading PM Values"); + delay(20); // From Sensirion Datasheet + + uint8_t dataBuffer[16]; + size_t receivedNumber = readBuffer(&dataBuffer[0], 24); + if (receivedNumber == 0) { + LOG_ERROR("SEN5X: Error getting values"); + return false; + } + + // Get the integers + uint16_t uint_pM1p0 = static_cast((dataBuffer[0] << 8) | dataBuffer[1]); + uint16_t uint_pM2p5 = static_cast((dataBuffer[2] << 8) | dataBuffer[3]); + uint16_t uint_pM4p0 = static_cast((dataBuffer[4] << 8) | dataBuffer[5]); + uint16_t uint_pM10p0 = static_cast((dataBuffer[6] << 8) | dataBuffer[7]); + + int16_t int_humidity = static_cast((dataBuffer[8] << 8) | dataBuffer[9]); + int16_t int_temperature = static_cast((dataBuffer[10] << 8) | dataBuffer[11]); + int16_t int_vocIndex = static_cast((dataBuffer[12] << 8) | dataBuffer[13]); + int16_t int_noxIndex = static_cast((dataBuffer[14] << 8) | dataBuffer[15]); + + // Convert values based on Sensirion Arduino lib + sen5xmeasurement.pM1p0 = !isnan(uint_pM1p0) ? uint_pM1p0 / 10 : UINT16_MAX; + sen5xmeasurement.pM2p5 = !isnan(uint_pM2p5) ? uint_pM2p5 / 10 : UINT16_MAX; + sen5xmeasurement.pM4p0 = !isnan(uint_pM4p0) ? uint_pM4p0 / 10 : UINT16_MAX; + sen5xmeasurement.pM10p0 = !isnan(uint_pM10p0) ? uint_pM10p0 / 10 : UINT16_MAX; + sen5xmeasurement.humidity = !isnan(int_humidity) ? int_humidity / 100.0f : FLT_MAX; + sen5xmeasurement.temperature = !isnan(int_temperature) ? int_temperature / 200.0f : FLT_MAX; + sen5xmeasurement.vocIndex = !isnan(int_vocIndex) ? int_vocIndex / 10.0f : FLT_MAX; + sen5xmeasurement.noxIndex = !isnan(int_noxIndex) ? int_noxIndex / 10.0f : FLT_MAX; + + LOG_DEBUG("Got: pM1p0=%u, pM2p5=%u, pM4p0=%u, pM10p0=%u", + sen5xmeasurement.pM1p0, sen5xmeasurement.pM2p5, + sen5xmeasurement.pM4p0, sen5xmeasurement.pM10p0); + + if (model != SEN50) { + LOG_DEBUG("Got: humidity=%.2f, temperature=%.2f, vocIndex=%.2f", + sen5xmeasurement.humidity, sen5xmeasurement.temperature, + sen5xmeasurement.vocIndex); + } + + if (model == SEN55) { + LOG_DEBUG("Got: noxIndex=%.2f", + sen5xmeasurement.noxIndex); + } + + return true; +} + +bool SEN5XSensor::readPNValues(bool cumulative) +{ + if (!sendCommand(SEN5X_READ_PM_VALUES)){ + LOG_ERROR("SEN5X: Error sending read command"); + return false; + } + + LOG_DEBUG("SEN5X: Reading PN Values"); + delay(20); // From Sensirion Datasheet + + uint8_t dataBuffer[20]; + size_t receivedNumber = readBuffer(&dataBuffer[0], 30); + if (receivedNumber == 0) { + LOG_ERROR("SEN5X: Error getting PN values"); + return false; + } + + // Get the integers + // uint16_t uint_pM1p0 = static_cast((dataBuffer[0] << 8) | dataBuffer[1]); + // uint16_t uint_pM2p5 = static_cast((dataBuffer[2] << 8) | dataBuffer[3]); + // uint16_t uint_pM4p0 = static_cast((dataBuffer[4] << 8) | dataBuffer[5]); + // uint16_t uint_pM10p0 = static_cast((dataBuffer[6] << 8) | dataBuffer[7]); + uint16_t uint_pN0p5 = static_cast((dataBuffer[8] << 8) | dataBuffer[9]); + uint16_t uint_pN1p0 = static_cast((dataBuffer[10] << 8) | dataBuffer[11]); + uint16_t uint_pN2p5 = static_cast((dataBuffer[12] << 8) | dataBuffer[13]); + uint16_t uint_pN4p0 = static_cast((dataBuffer[14] << 8) | dataBuffer[15]); + uint16_t uint_pN10p0 = static_cast((dataBuffer[16] << 8) | dataBuffer[17]); + uint16_t uint_tSize = static_cast((dataBuffer[18] << 8) | dataBuffer[19]); + + // Convert values based on Sensirion Arduino lib + // Multiply by 100 for converting from #/cm3 to #/0.1l for PN values + sen5xmeasurement.pN0p5 = !isnan(uint_pN0p5) ? uint_pN0p5 / 10 * 100 : UINT32_MAX; + sen5xmeasurement.pN1p0 = !isnan(uint_pN1p0) ? uint_pN1p0 / 10 * 100 : UINT32_MAX; + sen5xmeasurement.pN2p5 = !isnan(uint_pN2p5) ? uint_pN2p5 / 10 * 100 : UINT32_MAX; + sen5xmeasurement.pN4p0 = !isnan(uint_pN4p0) ? uint_pN4p0 / 10 * 100 : UINT32_MAX; + sen5xmeasurement.pN10p0 = !isnan(uint_pN10p0) ? uint_pN10p0 / 10 * 100 : UINT32_MAX; + sen5xmeasurement.tSize = !isnan(uint_tSize) ? uint_tSize / 1000.0f : FLT_MAX; + + // Remove accumuluative values: + // https://github.com/fablabbcn/smartcitizen-kit-2x/issues/85 + if (!cumulative) { + sen5xmeasurement.pN10p0 -= sen5xmeasurement.pN4p0; + sen5xmeasurement.pN4p0 -= sen5xmeasurement.pN2p5; + sen5xmeasurement.pN2p5 -= sen5xmeasurement.pN1p0; + sen5xmeasurement.pN1p0 -= sen5xmeasurement.pN0p5; + } + + LOG_DEBUG("Got: pN0p5=%u, pN1p0=%u, pN2p5=%u, pN4p0=%u, pN10p0=%u, tSize=%.2f", + sen5xmeasurement.pN0p5, sen5xmeasurement.pN1p0, + sen5xmeasurement.pN2p5, sen5xmeasurement.pN4p0, + sen5xmeasurement.pN10p0, sen5xmeasurement.tSize + ); + + return true; +} + +// TODO - Decide if we want to have this here or not +// bool SEN5XSensor::readRawValues() +// { +// if (!sendCommand(SEN5X_READ_RAW_VALUES)){ +// LOG_ERROR("SEN5X: Error sending read command"); +// return false; +// } +// delay(20); // From Sensirion Datasheet + +// uint8_t dataBuffer[8]; +// size_t receivedNumber = readBuffer(&dataBuffer[0], 12); +// if (receivedNumber == 0) { +// LOG_ERROR("SEN5X: Error getting Raw values"); +// return false; +// } + +// // Get values +// rawHumidity = static_cast((dataBuffer[0] << 8) | dataBuffer[1]); +// rawTemperature = static_cast((dataBuffer[2] << 8) | dataBuffer[3]); +// rawVoc = static_cast((dataBuffer[4] << 8) | dataBuffer[5]); +// rawNox = static_cast((dataBuffer[6] << 8) | dataBuffer[7]); + +// return true; +// } + +uint8_t SEN5XSensor::getMeasurements() +{ + // Try to get new data + if (!sendCommand(SEN5X_READ_DATA_READY)){ + LOG_ERROR("SEN5X: Error sending command data ready flag"); + return 2; + } + delay(20); // From Sensirion Datasheet + + uint8_t dataReadyBuffer[3]; + size_t charNumber = readBuffer(&dataReadyBuffer[0], 3); + if (charNumber == 0) { + LOG_ERROR("SEN5X: Error getting device version value"); + return 2; + } + + bool data_ready = dataReadyBuffer[1]; + + if (!data_ready) { + LOG_INFO("SEN5X: Data is not ready"); + return 1; + } + + if(!readValues()) { + LOG_ERROR("SEN5X: Error getting readings"); + return 2; + } + + if(!readPNValues(false)) { + LOG_ERROR("SEN5X: Error getting PN readings"); + return 2; + } + + // if(!readRawValues()) { + // LOG_ERROR("SEN5X: Error getting Raw readings"); + // return 2; + // } + + return 0; +} + +int32_t SEN5XSensor::wakeUpTimeMs() +{ + return SEN5X_WARMUP_MS_2; +} + +int32_t SEN5XSensor::pendingForReadyMs(){ + uint32_t now; + now = getTime(); + uint32_t sincePmMeasureStarted = (now - pmMeasureStarted)*1000; + LOG_DEBUG("SEN5X: Since measure started: %ums", sincePmMeasureStarted); + + switch (state) { + case SEN5X_MEASUREMENT: { + + if (sincePmMeasureStarted < SEN5X_WARMUP_MS_1) { + LOG_INFO("SEN5X: not enough time passed since starting measurement"); + return SEN5X_WARMUP_MS_1 - sincePmMeasureStarted; + } + + if (!pmMeasureStarted) { + pmMeasureStarted = now; + } + + // Get PN values to check if we are above or below threshold + readPNValues(true); + + // If the reading is low (the tyhreshold is in #/cm3) and second warmUp hasn't passed we return to come back later + if ((sen5xmeasurement.pN4p0 / 100) < SEN5X_PN4P0_CONC_THD && sincePmMeasureStarted < SEN5X_WARMUP_MS_2) { + LOG_INFO("SEN5X: Concentration is low, we will ask again in the second warm up period"); + state = SEN5X_MEASUREMENT_2; + // Report how many seconds are pending to cover the first warm up period + return SEN5X_WARMUP_MS_2 - sincePmMeasureStarted; + } + return 0; + } + case SEN5X_MEASUREMENT_2: { + if (sincePmMeasureStarted < SEN5X_WARMUP_MS_2) { + // Report how many seconds are pending to cover the first warm up period + return SEN5X_WARMUP_MS_2 - sincePmMeasureStarted; + } + return 0; + } + default: { + return -1; + } + } +} + +bool SEN5XSensor::getMetrics(meshtastic_Telemetry *measurement) +{ + LOG_INFO("SEN5X: Attempting to get metrics"); + if (!isActive()){ + LOG_INFO("SEN5X: not in measurement mode"); + return false; + } + + uint8_t response; + response = getMeasurements(); + + if (response == 0) { + measurement->variant.air_quality_metrics.has_sen5xdata = true; + if (sen5xmeasurement.pM1p0 != UINT16_MAX) { + measurement->variant.air_quality_metrics.sen5xdata.has_pm10_standard = true; + measurement->variant.air_quality_metrics.sen5xdata.pm10_standard = sen5xmeasurement.pM1p0; + } + if (sen5xmeasurement.pM2p5 != UINT16_MAX) { + measurement->variant.air_quality_metrics.sen5xdata.has_pm25_standard = true; + measurement->variant.air_quality_metrics.sen5xdata.pm25_standard = sen5xmeasurement.pM2p5; + } + if (sen5xmeasurement.pM4p0 != UINT16_MAX) { + measurement->variant.air_quality_metrics.sen5xdata.has_pm40_standard = true; + measurement->variant.air_quality_metrics.sen5xdata.pm40_standard = sen5xmeasurement.pM4p0; + } + if (sen5xmeasurement.pM10p0 != UINT16_MAX) { + measurement->variant.air_quality_metrics.sen5xdata.has_pm100_standard = true; + measurement->variant.air_quality_metrics.sen5xdata.pm100_standard = sen5xmeasurement.pM10p0; + } + if (sen5xmeasurement.pN0p5 != UINT32_MAX) { + measurement->variant.air_quality_metrics.sen5xdata.has_particles_05um = true; + measurement->variant.air_quality_metrics.sen5xdata.particles_05um = sen5xmeasurement.pN0p5; + } + if (sen5xmeasurement.pN1p0 != UINT32_MAX) { + measurement->variant.air_quality_metrics.sen5xdata.has_particles_10um = true; + measurement->variant.air_quality_metrics.sen5xdata.particles_10um = sen5xmeasurement.pN1p0; + } + if (sen5xmeasurement.pN2p5 != UINT32_MAX) { + measurement->variant.air_quality_metrics.sen5xdata.has_particles_25um = true; + measurement->variant.air_quality_metrics.sen5xdata.particles_25um = sen5xmeasurement.pN2p5; + } + if (sen5xmeasurement.pN4p0 != UINT32_MAX) { + measurement->variant.air_quality_metrics.sen5xdata.has_particles_40um = true; + measurement->variant.air_quality_metrics.sen5xdata.particles_40um = sen5xmeasurement.pN4p0; + } + if (sen5xmeasurement.pN10p0 != UINT32_MAX) { + measurement->variant.air_quality_metrics.sen5xdata.has_particles_100um = true; + measurement->variant.air_quality_metrics.sen5xdata.particles_100um = sen5xmeasurement.pN10p0; + } + if (sen5xmeasurement.tSize != FLT_MAX) { + measurement->variant.air_quality_metrics.sen5xdata.has_particles_tps = true; + measurement->variant.air_quality_metrics.sen5xdata.particles_tps = sen5xmeasurement.tSize; + } + + if (model != SEN50) { + if (sen5xmeasurement.humidity!= FLT_MAX) { + measurement->variant.air_quality_metrics.sen5xdata.has_pm_humidity = true; + measurement->variant.air_quality_metrics.sen5xdata.pm_humidity = sen5xmeasurement.humidity; + } + if (sen5xmeasurement.temperature!= FLT_MAX) { + measurement->variant.air_quality_metrics.sen5xdata.has_pm_temperature = true; + measurement->variant.air_quality_metrics.sen5xdata.pm_temperature = sen5xmeasurement.temperature; + } + if (sen5xmeasurement.noxIndex!= FLT_MAX) { + measurement->variant.air_quality_metrics.sen5xdata.has_pm_voc_idx = true; + measurement->variant.air_quality_metrics.sen5xdata.pm_voc_idx = sen5xmeasurement.vocIndex; + } + } + + if (model == SEN55) { + if (sen5xmeasurement.noxIndex!= FLT_MAX) { + measurement->variant.air_quality_metrics.sen5xdata.has_pm_nox_idx = true; + measurement->variant.air_quality_metrics.sen5xdata.pm_nox_idx = sen5xmeasurement.noxIndex; + } + } + + + return true; + } else if (response == 1) { + // TODO return because data was not ready yet + // Should this return false? + idle(); + return false; + } else if (response == 2) { + // Return with error for non-existing data + idle(); + return false; + } + + return true; +} + +void SEN5XSensor::setMode(bool setOneShot) { + oneShotMode = setOneShot; +} + +AdminMessageHandleResult SEN5XSensor::handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) +{ + AdminMessageHandleResult result; + result = AdminMessageHandleResult::NOT_HANDLED; + + + switch (request->which_payload_variant) { + case meshtastic_AdminMessage_sensor_config_tag: + if (!request->sensor_config.has_sen5x_config) { + result = AdminMessageHandleResult::NOT_HANDLED; + break; + } + + // TODO - Add admin command to set temperature offset + // Check for temperature offset + // if (request->sensor_config.sen5x_config.has_set_temperature) { + // this->setTemperature(request->sensor_config.sen5x_config.set_temperature); + // } + + // Check for one-shot/continuous mode request + if (request->sensor_config.sen5x_config.has_set_one_shot_mode) { + this->setMode(request->sensor_config.sen5x_config.set_one_shot_mode); + } + + result = AdminMessageHandleResult::HANDLED; + break; + + default: + result = AdminMessageHandleResult::NOT_HANDLED; + } + + return result; +} + +#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SEN5XSensor.h b/src/modules/Telemetry/Sensor/SEN5XSensor.h new file mode 100644 index 00000000000..91d7724ca23 --- /dev/null +++ b/src/modules/Telemetry/Sensor/SEN5XSensor.h @@ -0,0 +1,161 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "TelemetrySensor.h" +#include "Wire.h" +#include "RTC.h" + +// Warm up times for SEN5X from the datasheet +#ifndef SEN5X_WARMUP_MS_1 +#define SEN5X_WARMUP_MS_1 15000 +#endif + +#ifndef SEN5X_WARMUP_MS_2 +#define SEN5X_WARMUP_MS_2 30000 +#endif + +#ifndef SEN5X_I2C_CLOCK_SPEED +#define SEN5X_I2C_CLOCK_SPEED 100000 +#endif + +/* +Time after which the sensor can go to sleep, as the warmup period has passed +and the VOCs sensor will is allowed to stop (although needs to recover the state +each time) +*/ +#ifndef SEN5X_VOC_STATE_WARMUP_S +/* Note for Testing 5' is enough +Sensirion recommends 1h +This can be bypassed completely if switching to low-power RHT/Gas mode and setting +SEN5X_VOC_STATE_WARMUP_S 0 +*/ +#define SEN5X_VOC_STATE_WARMUP_S 3600 +#endif + +#define ONE_WEEK_IN_SECONDS 604800 + +struct _SEN5XMeasurements { + uint16_t pM1p0; + uint16_t pM2p5; + uint16_t pM4p0; + uint16_t pM10p0; + uint32_t pN0p5; + uint32_t pN1p0; + uint32_t pN2p5; + uint32_t pN4p0; + uint32_t pN10p0; + float tSize; + float humidity; + float temperature; + float vocIndex; + float noxIndex; +}; + +class SEN5XSensor : public TelemetrySensor +{ + private: + TwoWire * _bus{}; + uint8_t _address{}; + + bool getVersion(); + float firmwareVer = -1; + float hardwareVer = -1; + float protocolVer = -1; + bool findModel(); + + // Commands + #define SEN5X_RESET 0xD304 + #define SEN5X_GET_PRODUCT_NAME 0xD014 + #define SEN5X_GET_FIRMWARE_VERSION 0xD100 + #define SEN5X_START_MEASUREMENT 0x0021 + #define SEN5X_START_MEASUREMENT_RHT_GAS 0x0037 + #define SEN5X_STOP_MEASUREMENT 0x0104 + #define SEN5X_READ_DATA_READY 0x0202 + #define SEN5X_START_FAN_CLEANING 0x5607 + #define SEN5X_RW_VOCS_STATE 0x6181 + + #define SEN5X_READ_VALUES 0x03C4 + #define SEN5X_READ_RAW_VALUES 0x03D2 + #define SEN5X_READ_PM_VALUES 0x0413 + + #define SEN5X_VOC_VALID_TIME 600 + #define SEN5X_VOC_VALID_DATE 1514764800 + + enum SEN5Xmodel { SEN5X_UNKNOWN = 0, SEN50 = 0b001, SEN54 = 0b010, SEN55 = 0b100 }; + SEN5Xmodel model = SEN5X_UNKNOWN; + + enum SEN5XState { SEN5X_OFF, SEN5X_IDLE, SEN5X_RHTGAS_ONLY, SEN5X_MEASUREMENT, SEN5X_MEASUREMENT_2, SEN5X_CLEANING, SEN5X_NOT_DETECTED }; + SEN5XState state = SEN5X_OFF; + // Flag to work on one-shot (read and sleep), or continuous mode + bool oneShotMode = true; + void setMode(bool setOneShot); + bool vocStateValid(); + /* Sensirion recommends taking a reading after 15 seconds, + if the Particle number reading is over 100#/cm3 the reading is OK, + but if it is lower wait until 30 seconds and take it again. + See: https://sensirion.com/resource/application_note/low_power_mode/sen5x + */ + #define SEN5X_PN4P0_CONC_THD 100 + + bool sendCommand(uint16_t command); + bool sendCommand(uint16_t command, uint8_t* buffer, uint8_t byteNumber=0); + uint8_t readBuffer(uint8_t* buffer, uint8_t byteNumber); // Return number of bytes received + uint8_t sen5xCRC(uint8_t* buffer); + bool startCleaning(); + uint8_t getMeasurements(); + // bool readRawValues(); + bool readPNValues(bool cumulative); + bool readValues(); + + uint32_t pmMeasureStarted = 0; + uint32_t rhtGasMeasureStarted = 0; + _SEN5XMeasurements sen5xmeasurement {}; + + bool idle(bool checkState=true); + + protected: + // Store status of the sensor in this file + const char *sen5XStateFileName = "/prefs/sen5X.dat"; + meshtastic_SEN5XState sen5xstate = meshtastic_SEN5XState_init_zero; + + bool loadState(); + bool saveState(); + + // Cleaning State + uint32_t lastCleaning = 0; + bool lastCleaningValid = false; + + // VOC State + #define SEN5X_VOC_STATE_BUFFER_SIZE 8 + uint8_t vocState[SEN5X_VOC_STATE_BUFFER_SIZE] {}; + uint32_t vocTime = 0; + bool vocValid = false; + + bool vocStateFromSensor(); + bool vocStateToSensor(); + bool vocStateStable(); + bool vocStateRecent(uint32_t now); + + public: + + SEN5XSensor(); + virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; + virtual bool getMetrics(meshtastic_Telemetry *measurement) override; + + virtual bool isActive() override; + virtual void sleep() override; + virtual uint32_t wakeUp() override; + virtual bool canSleep() override { return true; } + virtual int32_t wakeUpTimeMs() override; + virtual int32_t pendingForReadyMs() override; + + // TODO - Add a way to take averages of samples + // This value represents the time needed for pending data + AdminMessageHandleResult handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, meshtastic_AdminMessage *response) override; +}; + + + +#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/TelemetrySensor.h b/src/modules/Telemetry/Sensor/TelemetrySensor.h index af51ddfad5a..9c745422c04 100644 --- a/src/modules/Telemetry/Sensor/TelemetrySensor.h +++ b/src/modules/Telemetry/Sensor/TelemetrySensor.h @@ -26,7 +26,6 @@ class TelemetrySensor this->status = 0; } - const char *sensorName; meshtastic_TelemetrySensorType sensorType = meshtastic_TelemetrySensorType_SENSOR_UNSET; unsigned status; bool initialized = false; @@ -56,13 +55,18 @@ class TelemetrySensor return AdminMessageHandleResult::NOT_HANDLED; } + const char *sensorName; // TODO: delete after migration bool hasSensor() { return nodeTelemetrySensorsMap[sensorType].first > 0; } + // Functions to sleep / wakeup sensors that support it - virtual void sleep(){}; + // These functions can save power consumption in cases like AQ + virtual void sleep() {}; virtual uint32_t wakeUp() { return 0; } - // Return active by default, override per sensor - virtual bool isActive() { return true; } + virtual bool isActive() { return true; } // Return true by default, override per sensor + virtual bool canSleep() { return false; } // Return false by default, override per sensor + virtual int32_t wakeUpTimeMs() { return 0; } + virtual int32_t pendingForReadyMs() { return 0; } #if WIRE_INTERFACES_COUNT > 1 // Set to true if Implementation only works first I2C port (Wire) diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp index fc1f27ea29c..2a59c0aab3e 100644 --- a/src/nimble/NimbleBluetooth.cpp +++ b/src/nimble/NimbleBluetooth.cpp @@ -686,6 +686,9 @@ class NimbleBluetoothServerCallback : public NimBLEServerCallbacks #ifdef NIMBLE_TWO if (ble->isDeInit) return; +#else + if (nimbleBluetooth && nimbleBluetooth->isDeInit) + return; #endif meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::DISCONNECTED); diff --git a/src/serialization/MeshPacketSerializer.cpp b/src/serialization/MeshPacketSerializer.cpp index a12972cb002..b2ea945498b 100644 --- a/src/serialization/MeshPacketSerializer.cpp +++ b/src/serialization/MeshPacketSerializer.cpp @@ -140,26 +140,38 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp, msgPayload["soil_temperature"] = new JSONValue(decoded->variant.environment_metrics.soil_temperature); } } else if (decoded->which_variant == meshtastic_Telemetry_air_quality_metrics_tag) { - if (decoded->variant.air_quality_metrics.has_pm10_standard) { - msgPayload["pm10"] = new JSONValue((unsigned int)decoded->variant.air_quality_metrics.pm10_standard); - } - if (decoded->variant.air_quality_metrics.has_pm25_standard) { - msgPayload["pm25"] = new JSONValue((unsigned int)decoded->variant.air_quality_metrics.pm25_standard); - } - if (decoded->variant.air_quality_metrics.has_pm100_standard) { - msgPayload["pm100"] = new JSONValue((unsigned int)decoded->variant.air_quality_metrics.pm100_standard); - } - if (decoded->variant.air_quality_metrics.has_pm10_environmental) { - msgPayload["pm10_e"] = - new JSONValue((unsigned int)decoded->variant.air_quality_metrics.pm10_environmental); - } - if (decoded->variant.air_quality_metrics.has_pm25_environmental) { - msgPayload["pm25_e"] = - new JSONValue((unsigned int)decoded->variant.air_quality_metrics.pm25_environmental); - } - if (decoded->variant.air_quality_metrics.has_pm100_environmental) { - msgPayload["pm100_e"] = - new JSONValue((unsigned int)decoded->variant.air_quality_metrics.pm100_environmental); + if (decoded->variant.air_quality_metrics.has_sen5xdata) { + if (decoded->variant.air_quality_metrics.sen5xdata.has_pm10_standard) { + msgPayload["pm10"] = new JSONValue((unsigned int)decoded->variant.air_quality_metrics.sen5xdata.pm10_standard); + } + if (decoded->variant.air_quality_metrics.sen5xdata.has_pm25_standard) { + msgPayload["pm25"] = new JSONValue((unsigned int)decoded->variant.air_quality_metrics.sen5xdata.pm25_standard); + } + if (decoded->variant.air_quality_metrics.sen5xdata.has_pm100_standard) { + msgPayload["pm100"] = new JSONValue((unsigned int)decoded->variant.air_quality_metrics.sen5xdata.pm100_standard); + } + } + if (decoded->variant.air_quality_metrics.has_pmsa003idata) { + if (decoded->variant.air_quality_metrics.pmsa003idata.has_pm10_standard) { + msgPayload["pm10"] = new JSONValue((unsigned int)decoded->variant.air_quality_metrics.pmsa003idata.pm10_standard); + } + if (decoded->variant.air_quality_metrics.pmsa003idata.has_pm25_standard) { + msgPayload["pm25"] = new JSONValue((unsigned int)decoded->variant.air_quality_metrics.pmsa003idata.pm25_standard); + } + if (decoded->variant.air_quality_metrics.pmsa003idata.has_pm100_standard) { + msgPayload["pm100"] = new JSONValue((unsigned int)decoded->variant.air_quality_metrics.pmsa003idata.pm100_standard); + } + } + if (decoded->variant.air_quality_metrics.has_scd4xdata) { + if (decoded->variant.air_quality_metrics.scd4xdata.has_co2) { + msgPayload["co2"] = new JSONValue((unsigned int)decoded->variant.air_quality_metrics.scd4xdata.co2); + } + if (decoded->variant.air_quality_metrics.scd4xdata.has_co2_temperature) { + msgPayload["co2_temperature"] = new JSONValue(decoded->variant.air_quality_metrics.scd4xdata.co2_temperature); + } + if (decoded->variant.air_quality_metrics.scd4xdata.has_co2_humidity) { + msgPayload["co2_humidity"] = new JSONValue(decoded->variant.air_quality_metrics.scd4xdata.co2_humidity); + } } } else if (decoded->which_variant == meshtastic_Telemetry_power_metrics_tag) { if (decoded->variant.power_metrics.has_ch1_voltage) { diff --git a/src/serialization/MeshPacketSerializer_nRF52.cpp b/src/serialization/MeshPacketSerializer_nRF52.cpp index 41f505b94e5..e7e07fc5e94 100644 --- a/src/serialization/MeshPacketSerializer_nRF52.cpp +++ b/src/serialization/MeshPacketSerializer_nRF52.cpp @@ -111,23 +111,38 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp, jsonObj["payload"]["radiation"] = decoded->variant.environment_metrics.radiation; } } else if (decoded->which_variant == meshtastic_Telemetry_air_quality_metrics_tag) { - if (decoded->variant.air_quality_metrics.has_pm10_standard) { - jsonObj["payload"]["pm10"] = (unsigned int)decoded->variant.air_quality_metrics.pm10_standard; + if (decoded->variant.air_quality_metrics.has_sen5xdata) { + if (decoded->variant.air_quality_metrics.sen5xdata.has_pm10_standard) { + jsonObj["payload"]["pm10"] = (unsigned int)decoded->variant.air_quality_metrics.sen5xdata.pm10_standard; + } + if (decoded->variant.air_quality_metrics.sen5xdata.has_pm25_standard) { + jsonObj["payload"]["pm25"] = (unsigned int)decoded->variant.air_quality_metrics.sen5xdata.pm25_standard; + } + if (decoded->variant.air_quality_metrics.sen5xdata.has_pm100_standard) { + jsonObj["payload"]["pm100"] = (unsigned int)decoded->variant.air_quality_metrics.sen5xdata.pm100_standard; + } } - if (decoded->variant.air_quality_metrics.has_pm25_standard) { - jsonObj["payload"]["pm25"] = (unsigned int)decoded->variant.air_quality_metrics.pm25_standard; + if (decoded->variant.air_quality_metrics.has_pmsa003idata) { + if (decoded->variant.air_quality_metrics.pmsa003idata.has_pm10_standard) { + jsonObj["payload"]["pm10"] = (unsigned int)decoded->variant.air_quality_metrics.pmsa003idata.pm10_standard; + } + if (decoded->variant.air_quality_metrics.pmsa003idata.has_pm25_standard) { + jsonObj["payload"]["pm25"] = (unsigned int)decoded->variant.air_quality_metrics.pmsa003idata.pm25_standard; + } + if (decoded->variant.air_quality_metrics.pmsa003idata.has_pm100_standard) { + jsonObj["payload"]["pm100"] = (unsigned int)decoded->variant.air_quality_metrics.pmsa003idata.pm100_standard; + } } - if (decoded->variant.air_quality_metrics.has_pm100_standard) { - jsonObj["payload"]["pm100"] = (unsigned int)decoded->variant.air_quality_metrics.pm100_standard; - } - if (decoded->variant.air_quality_metrics.has_pm10_environmental) { - jsonObj["payload"]["pm10_e"] = (unsigned int)decoded->variant.air_quality_metrics.pm10_environmental; - } - if (decoded->variant.air_quality_metrics.has_pm25_environmental) { - jsonObj["payload"]["pm25_e"] = (unsigned int)decoded->variant.air_quality_metrics.pm25_environmental; - } - if (decoded->variant.air_quality_metrics.has_pm100_environmental) { - jsonObj["payload"]["pm100_e"] = (unsigned int)decoded->variant.air_quality_metrics.pm100_environmental; + if (decoded->variant.air_quality_metrics.has_scd4xdata) { + if (decoded->variant.air_quality_metrics.scd4xdata.has_co2) { + jsonObj["payload"]["co2"] = (unsigned int)decoded->variant.air_quality_metrics.scd4xdata.co2; + } + if (decoded->variant.air_quality_metrics.scd4xdata.has_co2_temperature) { + jsonObj["payload"]["co2_temperature"] = (unsigned int)decoded->variant.air_quality_metrics.scd4xdata.co2_temperature; + } + if (decoded->variant.air_quality_metrics.scd4xdata.has_co2_humidity) { + jsonObj["payload"]["co2_humidity"] = (unsigned int)decoded->variant.air_quality_metrics.scd4xdata.co2has_co2_humidity; + } } } else if (decoded->which_variant == meshtastic_Telemetry_power_metrics_tag) { if (decoded->variant.power_metrics.has_ch1_voltage) { diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index b86420291c5..f99ee9a6ffa 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -29,7 +29,7 @@ lib_deps = # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX lovyan03/LovyanGFX@1.2.19 # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/pine64/libch341-spi-userspace gitBranch=main - https://github.com/pine64/libch341-spi-userspace/archive/23c42319a69cffcb65868e3c72e6bed83974a393.zip + https://github.com/pine64/libch341-spi-userspace/archive/af9bc27c9c30fa90772279925b7c5913dff789b4.zip # renovate: datasource=custom.pio depName=adafruit/Adafruit seesaw Library packageName=adafruit/library/Adafruit seesaw Library adafruit/Adafruit seesaw Library@1.7.9 # renovate: datasource=git-refs depName=RAK12034-BMX160 packageName=https://github.com/RAKWireless/RAK12034-BMX160 gitBranch=main