#include "co2_sensor.h" #include "config.h" #include "ntp.h" #include "led_effects.h" #include "sensor_console.h" #include // The SCD30 from Sensirion is a high quality Nondispersive Infrared (NDIR) based CO₂ sensor capable of detecting 400 to 10000ppm with an accuracy of ±(30ppm+3%). // https://github.com/sparkfun/SparkFun_SCD30_Arduino_Library #include "src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.h" // From: http://librarymanager/All#SparkFun_SCD30 namespace config { // UPPERCASE values should be defined in config.h uint16_t measurement_timestep = MEASUREMENT_TIMESTEP; // [s] Value between 2 and 1800 (range for SCD30 sensor). const uint16_t altitude_above_sea_level = ALTITUDE_ABOVE_SEA_LEVEL; // [m] uint16_t co2_calibration_level = ATMOSPHERIC_CO2_CONCENTRATION; // [ppm] const uint16_t measurement_timestep_bootup = 5; // [s] Measurement timestep during acclimatization. const uint8_t max_deviation_during_bootup = 20; // [%] const int8_t max_deviation_during_calibration = 30; // [ppm] const int16_t timestep_during_calibration = 10; // [s] WARNING: Measurements can be unreliable for timesteps shorter than 10s. const int8_t stable_measurements_before_calibration = 120 / timestep_during_calibration; // [-] Stable measurements during at least 2 minutes. const uint16_t co2_alert_threshold = 2000; // [ppm] Display a flashing led ring, if concentration exceeds this value #ifdef TEMPERATURE_OFFSET // Residual heat from CO2 sensor seems to be high enough to change the temperature reading. How much should it be offset? // NOTE: Sign isn't relevant. The returned temperature will always be shifted down. const float temperature_offset = TEMPERATURE_OFFSET; // [K] #else const float temperature_offset = -3.0; // [K] Temperature measured by sensor is usually at least 3K too high. #endif bool auto_calibrate_sensor = AUTO_CALIBRATE_SENSOR; // [true / false] const bool debug_sensor_states = false; // If true, log state transitions over serial console } namespace sensor { SCD30 scd30; uint16_t co2 = 0; float temperature = 0; float humidity = 0; char timestamp[23]; int16_t stable_measurements = 0; /** * Define sensor states * BOOTUP -> initial state, until first >0 ppm values are returned * READY -> sensor does output valid information (> 0 ppm) and no other condition takes place * NEEDS_CALIBRATION -> sensor measurements are too low (< 250 ppm) * PREPARE_CALIBRATION_UNSTABLE -> forced calibration was initiated, last measurements were too far apart * PREPARE_CALIBRATION_STABLE -> forced calibration was initiated, last measurements were close to each others */ enum state { BOOTUP, READY, NEEDS_CALIBRATION, PREPARE_CALIBRATION_UNSTABLE, PREPARE_CALIBRATION_STABLE }; const char *state_names[] = { "BOOTUP", "READY", "NEEDS_CALIBRATION", "PREPARE_CALIBRATION_UNSTABLE", "PREPARE_CALIBRATION_STABLE" }; state current_state = BOOTUP; void switchState(state); void initialize() { #if defined(ESP8266) Wire.begin(12, 14); // ESP8266 - D6, D5; #endif #if defined(ESP32) Wire.begin(21, 22); // ESP32 /** * SCD30 ESP32 * VCC --- 3V3 * GND --- GND * SCL --- SCL (GPIO22) //NOTE: GPIO3 Would be more convenient (right next to GND) * SDA --- SDA (GPIO21) //NOTE: GPIO1 would be more convenient (right next to GPO3) */ #endif Serial.println(); scd30.enableDebugging(); // Prints firmware version in the console. if (!scd30.begin(config::auto_calibrate_sensor)) { Serial.println(F("ERROR - CO2 sensor not detected. Please check wiring!")); led_effects::showKITTWheel(color::red, 30); ESP.restart(); } // Changes of the SCD30's measurement timestep do not come into effect // before the next measurement takes place. That means that after a hard reset // of the ESP the SCD30 sometimes needs a long time until switching back to 2 s // for acclimatization. Resetting it after startup seems to fix this behaviour. scd30.reset(); Serial.print(F("Setting temperature offset to -")); Serial.print(abs(config::temperature_offset)); Serial.println(F(" K.")); scd30.setTemperatureOffset(abs(config::temperature_offset)); // setTemperatureOffset only accepts positive numbers, but shifts the temperature down. delay(100); Serial.print(F("Temperature offset is : -")); Serial.print(scd30.getTemperatureOffset()); Serial.println(F(" K")); Serial.print(F("Auto-calibration is ")); Serial.println(config::auto_calibrate_sensor ? "ON." : "OFF."); // SCD30 has its own timer. //NOTE: The timer seems to be inaccurate, though, possibly depending on voltage. Should it be offset? Serial.println(); Serial.print(F("Setting SCD30 timestep to ")); Serial.print(config::measurement_timestep_bootup); Serial.println(F(" s during acclimatization.")); scd30.setMeasurementInterval(config::measurement_timestep_bootup); // [s] sensor_console::defineIntCommand("co2", setCO2forDebugging, F("1500 (Sets co2 level, for debugging purposes)")); sensor_console::defineIntCommand("timer", setTimer, F("30 (Sets measurement interval, in s)")); sensor_console::defineCommand("calibrate", startCalibrationProcess, F("(Starts calibration process)")); sensor_console::defineIntCommand("calibrate", calibrateSensorToSpecificPPM, F("600 (Starts calibration process, to given ppm)")); sensor_console::defineIntCommand("calibrate!", calibrateSensorRightNow, F("600 (Calibrates right now, to given ppm)")); sensor_console::defineIntCommand("auto_calibrate", setAutoCalibration, F("0/1 (Disables/enables autocalibration)")); sensor_console::defineCommand("reset_scd", resetSCD, F("(Resets SCD30)")); } bool hasSensorSettled() { static uint16_t last_co2 = 0; uint16_t delta; delta = abs(co2 - last_co2); last_co2 = co2; // We assume the sensor has acclimated to the environment if measurements // change less than a specified percentage of the current value. return (co2 > 0 && delta < ((uint32_t) co2 * config::max_deviation_during_bootup / 100)); } bool enoughStableMeasurements() { static int16_t previous_co2 = 0; if (co2 > (previous_co2 - config::max_deviation_during_calibration) && co2 < (previous_co2 + config::max_deviation_during_calibration)) { stable_measurements++; Serial.print(F("Number of stable measurements : ")); Serial.print(stable_measurements); Serial.print(F(" / ")); Serial.println(config::stable_measurements_before_calibration); switchState(PREPARE_CALIBRATION_STABLE); } else { stable_measurements = 0; switchState(PREPARE_CALIBRATION_UNSTABLE); } previous_co2 = co2; return (stable_measurements == config::stable_measurements_before_calibration); } void startCalibrationProcess() { /** From the sensor documentation: * Before applying FRC, SCD30 needs to be operated for 2 minutes with the desired measurement period in continuous mode. */ Serial.print(F("Setting SCD30 timestep to ")); Serial.print(config::timestep_during_calibration); Serial.println(F("s, prior to calibration.")); scd30.setMeasurementInterval(config::timestep_during_calibration); // [s] The change will only take effect after next measurement. Serial.println(F("Waiting until the measurements are stable for at least 2 minutes.")); Serial.println(F("It could take a very long time.")); switchState(PREPARE_CALIBRATION_UNSTABLE); } void calibrate() { Serial.print(F("Calibrating SCD30 now...")); scd30.setAltitudeCompensation(config::altitude_above_sea_level); scd30.setForcedRecalibrationFactor(config::co2_calibration_level); Serial.println(F(" Done!")); Serial.println(F("Sensor calibrated.")); switchState(BOOTUP); // In order to stop the calibration and select the desired timestep. //WARNING: Do not reset the ampel or the SCD30! //At least one measurement needs to happen in order for the calibration to be correctly applied. } void logToSerial() { Serial.print(timestamp); Serial.print(F(" - co2(ppm): ")); Serial.print(co2); Serial.print(F(" temp(C): ")); Serial.print(temperature, 1); Serial.print(F(" humidity(%): ")); Serial.println(humidity, 1); } void switchState(state new_state) { if (new_state == current_state) { return; } if (config::debug_sensor_states) { Serial.print(F("Changing sensor state: ")); Serial.print(state_names[current_state]); Serial.print(F(" -> ")); Serial.println(state_names[new_state]); } current_state = new_state; } void switchStateForCurrentPPM() { if (current_state == BOOTUP) { if (!hasSensorSettled()) { return; } switchState(READY); Serial.println(F("Sensor acclimatization finished.")); Serial.print(F("Setting SCD30 timestep to ")); Serial.print(config::measurement_timestep); Serial.println(F(" s.")); if (config::measurement_timestep < 10) { Serial.println(F("WARNING: Timesteps shorter than 10s can lead to unreliable measurements!")); } scd30.setMeasurementInterval(config::measurement_timestep); // [s] } // Check for pre-calibration states first, because we do not want to // leave them before calibration is done. if ((current_state == PREPARE_CALIBRATION_UNSTABLE) || (current_state == PREPARE_CALIBRATION_STABLE)) { if (enoughStableMeasurements()) { calibrate(); } } else if (co2 < 250) { // Sensor should be calibrated. switchState(NEEDS_CALIBRATION); } else { switchState(READY); } } void displayCO2OnLedRing() { /** * Display data, even if it's "old" (with breathing). * A short delay is required in order to let background tasks run on the ESP8266. * see https://github.com/esp8266/Arduino/issues/3241#issuecomment-301290392 */ if (co2 < config::co2_alert_threshold) { led_effects::displayCO2color(co2); delay(100); } else { // Display a flashing led ring, if concentration exceeds a specific value led_effects::redAlert(); } } void showState() { switch (current_state) { case BOOTUP: led_effects::showWaitingLED(color::blue); break; case READY: displayCO2OnLedRing(); break; case NEEDS_CALIBRATION: led_effects::showWaitingLED(color::magenta); break; case PREPARE_CALIBRATION_UNSTABLE: led_effects::showWaitingLED(color::red); break; case PREPARE_CALIBRATION_STABLE: led_effects::showWaitingLED(color::green); break; default: Serial.println(F("Encountered unknown sensor state")); // This should not happen. } } /** Gets fresh data if available, checks calibration status, displays CO2 levels. * Returns true if fresh data is available, for further processing (e.g. MQTT, CSV or LoRa) */ bool processData() { bool freshData = scd30.dataAvailable(); if (freshData) { ntp::getLocalTime(timestamp); co2 = scd30.getCO2(); temperature = scd30.getTemperature(); humidity = scd30.getHumidity(); switchStateForCurrentPPM(); // Log every time fresh data is available. logToSerial(); } showState(); // Report data for further processing only if the data is reliable // (state 'READY') or manual calibration is necessary (state 'NEEDS_CALIBRATION'). return freshData && (current_state == READY || current_state == NEEDS_CALIBRATION); } /***************************************************************** * Callbacks for sensor commands * *****************************************************************/ void setCO2forDebugging(int32_t fakeCo2) { Serial.print(F("DEBUG. Setting CO2 to ")); co2 = fakeCo2; Serial.println(co2); switchStateForCurrentPPM(); } void setAutoCalibration(int32_t autoCalibration) { config::auto_calibrate_sensor = autoCalibration; scd30.setAutoSelfCalibration(autoCalibration); Serial.print(F("Setting auto-calibration to : ")); Serial.println(autoCalibration ? F("On.") : F("Off.")); } void setTimer(int32_t timestep) { if (timestep >= 2 && timestep <= 1800) { Serial.print(F("Setting Measurement Interval to : ")); Serial.print(timestep); Serial.println(F("s (change will only be applied after next measurement).")); scd30.setMeasurementInterval(timestep); config::measurement_timestep = timestep; led_effects::showKITTWheel(color::green, 1); } } void calibrateSensorToSpecificPPM(int32_t calibrationLevel) { if (calibrationLevel >= 400 && calibrationLevel <= 2000) { Serial.print(F("Force calibration, at ")); config::co2_calibration_level = calibrationLevel; Serial.print(config::co2_calibration_level); Serial.println(F(" ppm.")); startCalibrationProcess(); } } void calibrateSensorRightNow(int32_t calibrationLevel) { if (calibrationLevel >= 400 && calibrationLevel <= 2000) { Serial.print(F("Force calibration, right now, at ")); config::co2_calibration_level = calibrationLevel; Serial.print(config::co2_calibration_level); Serial.println(F(" ppm.")); calibrate(); } } void resetSCD() { Serial.print(F("Resetting SCD30...")); scd30.reset(); Serial.println(F("done.")); } }