co2_sensor.cpp 13.8 KB
Newer Older
1
2
#include "co2_sensor.h"

Eric Duminil's avatar
Eric Duminil committed
3
#include "web_config.h"
Eric Duminil's avatar
Eric Duminil committed
4
#include "ntp.h"
5
6
7
8
#include "led_effects.h"
#include "sensor_console.h"
#include <Wire.h>

Eric Duminil's avatar
Eric Duminil committed
9
10
11
12
// 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

Eric Duminil's avatar
Eric Duminil committed
13
14
#include "s8_uart.h" // Not used yet

15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#if (defined USE_SOFTWARE_SERIAL || defined ARDUINO_ARCH_RP2040)
  #define S8_RX_PIN 5         // Rx pin which the S8 Tx pin is attached to (change if it is needed)
  #define S8_TX_PIN 4         // Tx pin which the S8 Rx pin is attached to (change if it is needed)
#else
  #define S8_UART_PORT  1     // Change UART port if it is needed
#endif
/* END CONFIGURATION */

S8_UART *sensor_S8;
S8_sensor sensor2;


#ifdef USE_SOFTWARE_SERIAL
  SoftwareSerial S8_serial(S8_RX_PIN, S8_TX_PIN);
#else
  #if defined(ARDUINO_ARCH_RP2040)
    REDIRECT_STDOUT_TO(Serial)    // to use printf (Serial.printf not supported)
    UART S8_serial(S8_TX_PIN, S8_RX_PIN, NC, NC);
  #else
    HardwareSerial S8_serial(S8_UART_PORT);   
  #endif
#endif

38
namespace config {
39
  const uint16_t measurement_timestep_bootup = 5; // [s] Measurement timestep during acclimatization.
40
  const uint8_t max_deviation_during_bootup = 20; // [%]
41
42
43
  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.
44
  const uint16_t co2_alert_threshold = 2000; // [ppm] Display a flashing led ring, if concentration exceeds this value
45
  const bool debug_sensor_states = false; // If true, log state transitions over serial console
46
47
48
49
}

namespace sensor {
  SCD30 scd30;
50
  uint16_t co2 = 0;
51
52
  float temperature = 0;
  float humidity = 0;
53
  char timestamp[23];
54
  int16_t stable_measurements = 0;
Käppler's avatar
Käppler committed
55
56
57

  /**
   * Define sensor states
58
   * BOOTUP -> initial state, until first >0 ppm values are returned
Käppler's avatar
Käppler committed
59
   * READY -> sensor does output valid information (> 0 ppm) and no other condition takes place
Eric Duminil's avatar
Eric Duminil committed
60
   * NEEDS_CALIBRATION -> sensor measurements are too low (< 250 ppm)
61
62
   * 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
Käppler's avatar
Käppler committed
63
   */
64
65
66
  enum state {
    BOOTUP,
    READY,
67
    NEEDS_CALIBRATION,
68
    PREPARE_CALIBRATION_UNSTABLE,
69
    PREPARE_CALIBRATION_STABLE
Käppler's avatar
Käppler committed
70
  };
71
72
73
  const char *state_names[] = {
      "BOOTUP",
      "READY",
74
      "NEEDS_CALIBRATION",
75
      "PREPARE_CALIBRATION_UNSTABLE",
76
      "PREPARE_CALIBRATION_STABLE" };
77
78

  state current_state = BOOTUP;
Käppler's avatar
Käppler committed
79
  void switchState(state);
Eric Duminil's avatar
Eric Duminil committed
80
81
82
83
  void setCO2forDebugging(int32_t fakeCo2);
  void calibrateSensorToSpecificPPM(int32_t calibrationLevel);
  void calibrateSensorRightNow(int32_t calibrationLevel);
  void setAutoCalibration(int32_t autoCalibration);
Käppler's avatar
Käppler committed
84

85
86
  void initialize() {
#if defined(ESP8266)
87
    Wire.begin(12, 14); // ESP8266 - D6, D5;
88
89
#endif
#if defined(ESP32)
Eric Duminil's avatar
Eric Duminil committed
90
    Wire.begin(21, 22); // ESP32
91
92
93
94
95
96
97
98
    /**
     *  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
99
100
    Serial.println();
    scd30.enableDebugging(); // Prints firmware version in the console.
101

102
    if (!scd30.begin(config::auto_calibrate_sensor)) {
Eric Duminil's avatar
Eric Duminil committed
103
104
105
      Serial.println(F("ERROR - CO2 sensor not detected. Please check wiring!"));
      led_effects::showKITTWheel(color::red, 30);
      ESP.restart();
106
107
    }

108
109
110
111
112
    // 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();
Käppler's avatar
Käppler committed
113

Eric Duminil's avatar
Eric Duminil committed
114
115
    //NOTE: It seems that the sensor needs some time for getting/setting temperature offset.
    delay(500);
116
    Serial.print(F("Setting temperature offset to -"));
117
    Serial.print(abs(config::temperature_offset));
Eric Duminil's avatar
Eric Duminil committed
118
    Serial.println(F(" K."));
119
    scd30.setTemperatureOffset(abs(config::temperature_offset)); // setTemperatureOffset only accepts positive numbers, but shifts the temperature down.
Eric Duminil's avatar
Eric Duminil committed
120
    delay(500);
121

Eric Duminil's avatar
Eric Duminil committed
122
123
124
    //NOTE: Even once the temperature offset is saved, the sensor still needs some time (~10 minutes?) to apply it.
    Serial.print(F("Temperature offset is : "));
    Serial.print(getTemperatureOffset());
Eric Duminil's avatar
Eric Duminil committed
125
    Serial.println(F(" K"));
126

127
    Serial.print(F("Auto-calibration is "));
128
    Serial.println(config::auto_calibrate_sensor ? "ON." : "OFF.");
129

130
131
132
133
    // 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 "));
134
    Serial.print(config::measurement_timestep_bootup);
Eric Duminil's avatar
Eric Duminil committed
135
    Serial.println(F(" s during acclimatization."));
136
    scd30.setMeasurementInterval(config::measurement_timestep_bootup); // [s]
137

Eric Duminil's avatar
Eric Duminil committed
138
    sensor_console::defineIntCommand("co2", setCO2forDebugging, F("1500 (Sets co2 level, for debugging)"));
139
140
    sensor_console::defineIntCommand("timer", setTimer, F("30 (Sets measurement interval, in s)"));
    sensor_console::defineCommand("calibrate", startCalibrationProcess, F("(Starts calibration process)"));
Eric Duminil's avatar
Eric Duminil committed
141
    sensor_console::defineIntCommand("calibrate", calibrateSensorToSpecificPPM,
142
        F("600 (Starts calibration process, to given ppm)"));
Eric Duminil's avatar
Eric Duminil committed
143
    sensor_console::defineIntCommand("calibrate!", calibrateSensorRightNow,
144
145
        F("600 (Calibrates right now, to given ppm)"));
    sensor_console::defineIntCommand("auto_calibrate", setAutoCalibration, F("0/1 (Disables/enables autocalibration)"));
146
    sensor_console::defineCommand("reset_scd", resetSCD, F("(Resets SCD30)"));
147
148
  }

149
150
151
152
153
154
155
  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.
Eric Duminil's avatar
Eric Duminil committed
156
    return (co2 > 0 && delta < ((uint32_t) co2 * config::max_deviation_during_bootup / 100));
157
158
  }

Eric Duminil's avatar
Eric Duminil committed
159
  bool enoughStableMeasurements() {
Eric Duminil's avatar
Eric Duminil committed
160
    static int16_t previous_co2 = 0;
Eric Duminil's avatar
Eric Duminil committed
161
162
    if (co2 > (previous_co2 - config::max_deviation_during_calibration)
        && co2 < (previous_co2 + config::max_deviation_during_calibration)) {
163
      stable_measurements++;
Eric Duminil's avatar
Eric Duminil committed
164
      Serial.print(F("Number of stable measurements : "));
165
166
167
      Serial.print(stable_measurements);
      Serial.print(F(" / "));
      Serial.println(config::stable_measurements_before_calibration);
168
      switchState(PREPARE_CALIBRATION_STABLE);
169
170
    } else {
      stable_measurements = 0;
171
      switchState(PREPARE_CALIBRATION_UNSTABLE);
172
    }
Eric Duminil's avatar
Eric Duminil committed
173
    previous_co2 = co2;
174
    return (stable_measurements == config::stable_measurements_before_calibration);
175
176
177
  }

  void startCalibrationProcess() {
178
    /** From the sensor documentation:
Eric Duminil's avatar
Eric Duminil committed
179
     * Before applying FRC, SCD30 needs to be operated for 2 minutes with the desired measurement period in continuous mode.
180
     */
181
182
183
184
    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.
185
186
    Serial.println(F("Waiting until the measurements are stable for at least 2 minutes."));
    Serial.println(F("It could take a very long time."));
187
    switchState(PREPARE_CALIBRATION_UNSTABLE);
188
189
  }

190
  void calibrate() {
191
    Serial.print(F("Calibrating SCD30 now..."));
192
193
    scd30.setAltitudeCompensation(config::altitude_above_sea_level);
    scd30.setForcedRecalibrationFactor(config::co2_calibration_level);
194
195
    Serial.println(F(" Done!"));
    Serial.println(F("Sensor calibrated."));
196
    switchState(BOOTUP); // In order to stop the calibration and select the desired timestep.
197
198
    //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.
199
  }
200

201
  void logToSerial() {
Eric Duminil's avatar
Eric Duminil committed
202
203
    Serial.print(timestamp);
    Serial.print(F(" - co2(ppm): "));
204
205
    Serial.print(co2);
    Serial.print(F(" temp(C): "));
206
    Serial.print(temperature, 1);
207
    Serial.print(F(" humidity(%): "));
208
    Serial.println(humidity, 1);
209
210
  }

Käppler's avatar
Käppler committed
211
  void switchState(state new_state) {
212
213
214
    if (new_state == current_state) {
      return;
    }
215
216
217
    if (config::debug_sensor_states) {
      Serial.print(F("Changing sensor state: "));
      Serial.print(state_names[current_state]);
Eric Duminil's avatar
Eric Duminil committed
218
      Serial.print(F(" -> "));
219
220
      Serial.println(state_names[new_state]);
    }
Käppler's avatar
Käppler committed
221
222
223
    current_state = new_state;
  }

224
  void switchStateForCurrentPPM() {
225
    if (current_state == BOOTUP) {
Eric Duminil's avatar
Eric Duminil committed
226
227
228
      if (!hasSensorSettled()) {
        return;
      }
229
230
231
      switchState(READY);
      Serial.println(F("Sensor acclimatization finished."));
      Serial.print(F("Setting SCD30 timestep to "));
232
      Serial.print(config::measurement_timestep);
Eric Duminil's avatar
Eric Duminil committed
233
      Serial.println(F(" s."));
234
      if (config::measurement_timestep < 10) {
235
236
        Serial.println(F("WARNING: Timesteps shorter than 10s can lead to unreliable measurements!"));
      }
237
      scd30.setMeasurementInterval(config::measurement_timestep); // [s]
238
    }
Eric Duminil's avatar
Eric Duminil committed
239
240
241

    // Check for pre-calibration states first, because we do not want to
    // leave them before calibration is done.
Käppler's avatar
Käppler committed
242
    if ((current_state == PREPARE_CALIBRATION_UNSTABLE) || (current_state == PREPARE_CALIBRATION_STABLE)) {
Eric Duminil's avatar
Eric Duminil committed
243
      if (enoughStableMeasurements()) {
244
        calibrate();
245
246
247
248
249
250
251
252
253
      }
    } else if (co2 < 250) {
      // Sensor should be calibrated.
      switchState(NEEDS_CALIBRATION);
    } else {
      switchState(READY);
    }
  }

254
255
256
  void displayCO2OnLedRing() {
    /**
     * Display data, even if it's "old" (with breathing).
Eric Duminil's avatar
Eric Duminil committed
257
     * A short delay is required in order to let background tasks run on the ESP8266.
258
     * see https://github.com/esp8266/Arduino/issues/3241#issuecomment-301290392
259
     */
260
    if (co2 < config::co2_alert_threshold) {
261
      led_effects::displayCO2color(co2);
262
      delay(100);
263
    } else {
264
      // Display a flashing led ring, if concentration exceeds a specific value
265
      led_effects::alert(color::red);
266
267
268
    }
  }

269
  void showState() {
270
271
272
273
274
    switch (current_state) {
    case BOOTUP:
      led_effects::showWaitingLED(color::blue);
      break;
    case READY:
275
      displayCO2OnLedRing();
276
      break;
277
    case NEEDS_CALIBRATION:
278
279
      led_effects::showWaitingLED(color::magenta);
      break;
280
    case PREPARE_CALIBRATION_UNSTABLE:
281
282
      led_effects::showWaitingLED(color::red);
      break;
283
    case PREPARE_CALIBRATION_STABLE:
284
285
286
      led_effects::showWaitingLED(color::green);
      break;
    default:
Eric Duminil's avatar
Eric Duminil committed
287
      Serial.println(F("Encountered unknown sensor state")); // This should not happen.
288
289
290
    }
  }

Eric Duminil's avatar
Eric Duminil committed
291
  /** Gets fresh data if available, checks calibration status, displays CO2 levels.
292
   * Returns true if fresh data is available, for further processing (e.g. MQTT, CSV or LoRa)
Eric Duminil's avatar
Eric Duminil committed
293
294
295
296
297
   */
  bool processData() {
    bool freshData = scd30.dataAvailable();

    if (freshData) {
298
      ntp::getLocalTime(timestamp);
Eric Duminil's avatar
Eric Duminil committed
299
300
301
      co2 = scd30.getCO2();
      temperature = scd30.getTemperature();
      humidity = scd30.getHumidity();
302

303
      switchStateForCurrentPPM();
304

305
306
      // Log every time fresh data is available.
      logToSerial();
307
308
    }

309
    showState();
310

311
312
313
    // 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);
314
  }
315

Eric Duminil's avatar
Eric Duminil committed
316
317
318
319
  float getTemperatureOffset() {
    return -abs(scd30.getTemperatureOffset());
  }

320
321
322
323
324
325
326
  /*****************************************************************
   * Callbacks for sensor commands                                 *
   *****************************************************************/
  void setCO2forDebugging(int32_t fakeCo2) {
    Serial.print(F("DEBUG. Setting CO2 to "));
    co2 = fakeCo2;
    Serial.println(co2);
327
    switchStateForCurrentPPM();
328
329
  }

330
  void setAutoCalibration(int32_t autoCalibration) {
331
    config::auto_calibrate_sensor = autoCalibration;
332
333
334
335
336
    scd30.setAutoSelfCalibration(autoCalibration);
    Serial.print(F("Setting auto-calibration to : "));
    Serial.println(autoCalibration ? F("On.") : F("Off."));
  }

337
338
339
340
  void setTimer(int32_t timestep) {
    if (timestep >= 2 && timestep <= 1800) {
      Serial.print(F("Setting Measurement Interval to : "));
      Serial.print(timestep);
Eric Duminil's avatar
Eric Duminil committed
341
      Serial.println(F("s (change will only be applied after next measurement)."));
Eric Duminil's avatar
Eric Duminil committed
342
      scd30.setMeasurementInterval(timestep);
343
      config::measurement_timestep = timestep;
344
345
346
347
348
349
350
      led_effects::showKITTWheel(color::green, 1);
    }
  }

  void calibrateSensorToSpecificPPM(int32_t calibrationLevel) {
    if (calibrationLevel >= 400 && calibrationLevel <= 2000) {
      Serial.print(F("Force calibration, at "));
351
352
      config::co2_calibration_level = calibrationLevel;
      Serial.print(config::co2_calibration_level);
Eric Duminil's avatar
Eric Duminil committed
353
      Serial.println(F(" ppm."));
Eric Duminil's avatar
Eric Duminil committed
354
      startCalibrationProcess();
355
356
357
358
    }
  }

  void calibrateSensorRightNow(int32_t calibrationLevel) {
Eric Duminil's avatar
Eric Duminil committed
359
360
    if (calibrationLevel >= 400 && calibrationLevel <= 2000) {
      Serial.print(F("Force calibration, right now, at "));
361
362
      config::co2_calibration_level = calibrationLevel;
      Serial.print(config::co2_calibration_level);
Eric Duminil's avatar
Eric Duminil committed
363
      Serial.println(F(" ppm."));
364
      calibrate();
Eric Duminil's avatar
Eric Duminil committed
365
    }
366
  }
367
368
369
370
371
372

  void resetSCD() {
    Serial.print(F("Resetting SCD30..."));
    scd30.reset();
    Serial.println(F("done."));
  }
373
}