co2_sensor.cpp 12 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
#include "led_effects.h"
#include "sensor_console.h"

Eric Duminil's avatar
Eric Duminil committed
8
#include "src/lib/S8_UART/s8_uart.h"
Eric Duminil's avatar
Eric Duminil committed
9

10
namespace config {
Eric Duminil's avatar
Eric Duminil committed
11
  const uint16_t measurement_timestep_bootup = 4; // [s] Measurement timestep during acclimatization.
12
  const uint8_t max_deviation_during_bootup = 20; // [%]
13
14
15
  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.
16
  const uint16_t co2_alert_threshold = 2000; // [ppm] Display a flashing led ring, if concentration exceeds this value
17
  const bool debug_sensor_states = false; // If true, log state transitions over serial console
18
19
}

Eric Duminil's avatar
Warning    
Eric Duminil committed
20
//WARNING: Be very careful when connecting S8 to +5V and GND. There's no protection if polarity is reversed, and S8 would break.
Eric Duminil's avatar
Eric Duminil committed
21
#if defined(ESP8266)
Eric Duminil's avatar
Note    
Eric Duminil committed
22
//NOTE: This library requires much less memory than SoftwareSerial.h
Eric Duminil's avatar
Eric Duminil committed
23
#  include "src/lib/Esp8266EdgeSoftwareSerial/ESP8266SoftwareSerial.h"
24
25
#  define S8_RX_PIN 13         // GPIO13, a.k.a. D7, connected to S8 Tx pin.
#  define S8_TX_PIN 15         // GPIO15, a.k.a. D8, connected to S8 Rx pin.
Eric Duminil's avatar
Eric Duminil committed
26
ESP8266SoftwareSerial S8_serial(S8_RX_PIN, S8_TX_PIN, false, 16); // 16 bytes buffer should be enough.
Eric Duminil's avatar
Eric Duminil committed
27
28
#endif
#if defined(ESP32)
29
30
31
32
// GPIO16 connected to S8 Tx pin.
// GPIO17 connected to S8 Rx pin.
#  define S8_UART_PORT  2
HardwareSerial S8_serial(S8_UART_PORT);
33
#endif
Eric Duminil's avatar
Eric Duminil committed
34

35
namespace sensor {
Eric Duminil's avatar
Eric Duminil committed
36
  S8_UART *sensor_S8;
Eric Duminil's avatar
Eric Duminil committed
37
  S8_sensor s8;
38
  uint16_t co2 = 0;
39
40
  float temperature = 0;
  float humidity = 0;
41
  char timestamp[23];
42
  int16_t stable_measurements = 0;
Eric Duminil's avatar
Eric Duminil committed
43
44
  // I'm not sure it's possible to change S8 measurement interval (constant 4s). But we can check every check_timestep seconds.
  uint16_t check_timestep = 0;
Käppler's avatar
Käppler committed
45
46
47

  /**
   * Define sensor states
48
   * BOOTUP -> initial state, until first >0 ppm values are returned
Käppler's avatar
Käppler committed
49
   * READY -> sensor does output valid information (> 0 ppm) and no other condition takes place
Eric Duminil's avatar
Eric Duminil committed
50
   * NEEDS_CALIBRATION -> sensor measurements are too low (< 250 ppm)
51
52
   * 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
53
   */
54
55
56
  enum state {
    BOOTUP,
    READY,
57
    NEEDS_CALIBRATION,
58
    PREPARE_CALIBRATION_UNSTABLE,
59
    PREPARE_CALIBRATION_STABLE
Käppler's avatar
Käppler committed
60
  };
61
62
63
  const char *state_names[] = {
      "BOOTUP",
      "READY",
64
      "NEEDS_CALIBRATION",
65
      "PREPARE_CALIBRATION_UNSTABLE",
66
      "PREPARE_CALIBRATION_STABLE" };
67

Eric Duminil's avatar
Eric Duminil committed
68
  state current_state = BOOTUP;
Käppler's avatar
Käppler committed
69
  void switchState(state);
Eric Duminil's avatar
Eric Duminil committed
70
71
72
73
  void setCO2forDebugging(int32_t fakeCo2);
  void calibrateSensorToSpecificPPM(int32_t calibrationLevel);
  void calibrateSensorRightNow(int32_t calibrationLevel);
  void setAutoCalibration(int32_t autoCalibration);
Eric Duminil's avatar
Eric Duminil committed
74
  void setTimer(int32_t timestep);
Käppler's avatar
Käppler committed
75

76
  void initialize() {
Eric Duminil's avatar
Eric Duminil committed
77
    Serial.println(F("Sensor   : Senseair S8"));
Eric Duminil's avatar
Eric Duminil committed
78
79
    S8_serial.begin(S8_BAUDRATE);
    sensor_S8 = new S8_UART(S8_serial);
80
    Serial.println();
81

Eric Duminil's avatar
Eric Duminil committed
82
    // Check if S8 is available
Eric Duminil's avatar
Eric Duminil committed
83
84
    sensor_S8->get_firmware_version(s8.firm_version);
    int len = strlen(s8.firm_version);
Eric Duminil's avatar
Eric Duminil committed
85
86
    if (len == 0) {
      Serial.println(F("ERROR - Senseair S8 CO2 sensor not detected. Please check wiring!"));
Eric Duminil's avatar
Eric Duminil committed
87
88
      led_effects::showKITTWheel(color::red, 30);
      ESP.restart();
89
90
    }

Eric Duminil's avatar
Eric Duminil committed
91
    // Show basic S8 sensor info
Eric Duminil's avatar
Eric Duminil committed
92
93
    Serial.print(F("S8 - Firmware : "));
    Serial.println(s8.firm_version);
Eric Duminil's avatar
Eric Duminil committed
94
    s8.sensor_id = sensor_S8->get_sensor_ID();
Eric Duminil's avatar
Eric Duminil committed
95
    Serial.print(F("S8 - ID : 0x"));
Eric Duminil's avatar
Eric Duminil committed
96
    printIntToHex(s8.sensor_id, 4);
Eric Duminil's avatar
Eric Duminil committed
97
    Serial.println();
Eric Duminil's avatar
Eric Duminil committed
98

Eric Duminil's avatar
Eric Duminil committed
99
100
101
102
103
104
105
    // S8 has its own timer (constant 4s)
    Serial.println();
    Serial.print(F("Setting S8 timestep to "));
    Serial.print(config::measurement_timestep_bootup);
    Serial.println(F(" s during acclimatization."));
    check_timestep = config::measurement_timestep_bootup;

Eric Duminil's avatar
Eric Duminil committed
106
107
    setAutoCalibration(config::auto_calibrate_sensor);

Eric Duminil's avatar
Eric Duminil committed
108
    sensor_console::defineIntCommand("co2", setCO2forDebugging, F("1500 (Sets co2 level, for debugging)"));
Eric Duminil's avatar
Eric Duminil committed
109
    sensor_console::defineIntCommand("timer", setTimer, F("30 (Sets measurement interval, in s)"));
Eric Duminil's avatar
Eric Duminil committed
110
111
112
113
114
115
    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)"));
116
117
  }

118
119
120
121
122
123
124
  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
125
    return (co2 > 0 && delta < ((uint32_t) co2 * config::max_deviation_during_bootup / 100));
126
127
  }

Eric Duminil's avatar
Eric Duminil committed
128
  bool enoughStableMeasurements() {
Eric Duminil's avatar
Eric Duminil committed
129
    static int16_t previous_co2 = 0;
Eric Duminil's avatar
Eric Duminil committed
130
131
    if (co2 > (previous_co2 - config::max_deviation_during_calibration)
        && co2 < (previous_co2 + config::max_deviation_during_calibration)) {
132
      stable_measurements++;
Eric Duminil's avatar
Eric Duminil committed
133
      Serial.print(F("Number of stable measurements : "));
134
135
136
      Serial.print(stable_measurements);
      Serial.print(F(" / "));
      Serial.println(config::stable_measurements_before_calibration);
137
      switchState(PREPARE_CALIBRATION_STABLE);
138
139
    } else {
      stable_measurements = 0;
140
      switchState(PREPARE_CALIBRATION_UNSTABLE);
141
    }
Eric Duminil's avatar
Eric Duminil committed
142
    previous_co2 = co2;
143
    return (stable_measurements == config::stable_measurements_before_calibration);
144
145
146
  }

  void startCalibrationProcess() {
Eric Duminil's avatar
Eric Duminil committed
147
148
149
150
151
152
153
154
155
156
    /**
     * Before applying manual calibration, S8 needs to be operated for 2 minutes with the desired measurement period in continuous mode.
     */
    Serial.print(F("Setting S8 timestep to "));
    Serial.print(config::timestep_during_calibration);
    Serial.println(F("s, prior to calibration."));
    check_timestep = config::timestep_during_calibration;
    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);
157
158
  }

159
  void calibrate() {
Eric Duminil's avatar
Eric Duminil committed
160
161
162
163
164
165
166
    Serial.print(F("Calibrating S8 now..."));
    //FIXME: Apparently, only to 400ppm, though.
    Serial.println(F("WARNING! FORCING CALIBRATION TO 400ppm, regardless of configuration."));
    sensor_S8->manual_calibration();
    Serial.println(F(" Done!"));
    Serial.println(F("Sensor calibrated."));
    switchState(BOOTUP); // In order to stop the calibration and select the desired timestep.
167
  }
168

169
  void logToSerial() {
Eric Duminil's avatar
Eric Duminil committed
170
171
    Serial.print(timestamp);
    Serial.print(F(" - co2(ppm): "));
172
    Serial.println(co2);
173
174
  }

Käppler's avatar
Käppler committed
175
  void switchState(state new_state) {
176
177
178
    if (new_state == current_state) {
      return;
    }
179
180
181
    if (config::debug_sensor_states) {
      Serial.print(F("Changing sensor state: "));
      Serial.print(state_names[current_state]);
Eric Duminil's avatar
Eric Duminil committed
182
      Serial.print(F(" -> "));
183
184
      Serial.println(state_names[new_state]);
    }
Käppler's avatar
Käppler committed
185
186
187
    current_state = new_state;
  }

188
  void switchStateForCurrentPPM() {
189
    if (current_state == BOOTUP) {
Eric Duminil's avatar
Eric Duminil committed
190
191
192
      if (!hasSensorSettled()) {
        return;
      }
193
194
      switchState(READY);
      Serial.println(F("Sensor acclimatization finished."));
Eric Duminil's avatar
Eric Duminil committed
195
196
197
198
      Serial.print(F("Setting S8 timestep to "));
      Serial.print(config::measurement_timestep);
      Serial.println(F(" s."));
      check_timestep = config::measurement_timestep; // [s]
199
    }
Eric Duminil's avatar
Eric Duminil committed
200
201
202

    // 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
203
    if ((current_state == PREPARE_CALIBRATION_UNSTABLE) || (current_state == PREPARE_CALIBRATION_STABLE)) {
Eric Duminil's avatar
Eric Duminil committed
204
      if (enoughStableMeasurements()) {
205
        calibrate();
206
207
208
209
210
211
212
213
214
      }
    } else if (co2 < 250) {
      // Sensor should be calibrated.
      switchState(NEEDS_CALIBRATION);
    } else {
      switchState(READY);
    }
  }

215
216
217
  void displayCO2OnLedRing() {
    /**
     * Display data, even if it's "old" (with breathing).
Eric Duminil's avatar
Eric Duminil committed
218
     * A short delay is required in order to let background tasks run on the ESP8266.
219
     * see https://github.com/esp8266/Arduino/issues/3241#issuecomment-301290392
220
     */
221
    if (co2 < config::co2_alert_threshold) {
222
      led_effects::displayCO2color(co2);
223
      delay(100);
224
    } else {
225
      // Display a flashing led ring, if concentration exceeds a specific value
226
      led_effects::alert(color::red);
227
228
229
    }
  }

230
  void showState() {
231
232
233
234
235
    switch (current_state) {
    case BOOTUP:
      led_effects::showWaitingLED(color::blue);
      break;
    case READY:
236
      displayCO2OnLedRing();
237
      break;
238
    case NEEDS_CALIBRATION:
239
240
      led_effects::showWaitingLED(color::magenta);
      break;
241
    case PREPARE_CALIBRATION_UNSTABLE:
242
243
      led_effects::showWaitingLED(color::red);
      break;
244
    case PREPARE_CALIBRATION_STABLE:
245
246
247
      led_effects::showWaitingLED(color::green);
      break;
    default:
Eric Duminil's avatar
Eric Duminil committed
248
      Serial.println(F("Encountered unknown sensor state")); // This should not happen.
249
250
251
    }
  }

Eric Duminil's avatar
Eric Duminil committed
252
  /** Gets fresh data if available, checks calibration status, displays CO2 levels.
253
   * Returns true if fresh data is available, for further processing (e.g. MQTT, CSV or LoRa)
Eric Duminil's avatar
Eric Duminil committed
254
255
   */
  bool processData() {
Eric Duminil's avatar
Eric Duminil committed
256
257
    static unsigned long last_measurement = 0;
    unsigned long now = seconds();
Eric Duminil's avatar
Eric Duminil committed
258
    bool freshData = now - last_measurement > check_timestep;
Eric Duminil's avatar
Eric Duminil committed
259
    if (freshData) {
Eric Duminil's avatar
Eric Duminil committed
260
      last_measurement = now;
261
      ntp::getLocalTime(timestamp);
Eric Duminil's avatar
Eric Duminil committed
262
      co2 = sensor_S8->get_co2();
Eric Duminil's avatar
Note    
Eric Duminil committed
263
      //NOTE: S8 really doesn't have any temperature or humidity available.
Eric Duminil's avatar
Eric Duminil committed
264
265
      temperature = 0.0;
      humidity = 0.0;
266

267
      switchStateForCurrentPPM();
268

269
270
      // Log every time fresh data is available.
      logToSerial();
271
272
    }

273
    showState();
274

275
276
277
    // 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);
278
  }
279

Eric Duminil's avatar
Eric Duminil committed
280
  float getTemperatureOffset() {
Eric Duminil's avatar
Eric Duminil committed
281
    return 0.0;
Eric Duminil's avatar
Eric Duminil committed
282
283
  }

284
285
286
287
288
289
290
  /*****************************************************************
   * Callbacks for sensor commands                                 *
   *****************************************************************/
  void setCO2forDebugging(int32_t fakeCo2) {
    Serial.print(F("DEBUG. Setting CO2 to "));
    co2 = fakeCo2;
    Serial.println(co2);
291
    switchStateForCurrentPPM();
292
293
  }

294
  void setAutoCalibration(int32_t autoCalibration) {
Eric Duminil's avatar
Eric Duminil committed
295
296
297
298
299
300
301
302
    int16_t autoCalibrationHours = sensor_S8->get_ABC_period();
    bool isAutoCalibrationOn = autoCalibrationHours > 0;
    Serial.print(F("Current autocalibration : "));
    Serial.print(autoCalibrationHours);
    Serial.println(F(" h."));
    if (isAutoCalibrationOn != autoCalibration) {
      Serial.print(F("Turn autocalibration "));
      Serial.print(autoCalibration ? F("on") : F("off"));
Eric Duminil's avatar
Note    
Eric Duminil committed
303
      // Default autocalibration period is 180h ~ 1 week
Eric Duminil's avatar
Eric Duminil committed
304
305
306
307
      sensor_S8->set_ABC_period(autoCalibration ? 180 : 0);
      delay(500);
      Serial.println(F(". Done!"));
    }
308
309
  }

310
  void setTimer(int32_t timestep) {
Eric Duminil's avatar
Eric Duminil committed
311
312
313
314
315
316
317
318
    if (timestep >= 4) {
      Serial.print(F("Setting Measurement Interval to : "));
      Serial.print(timestep);
      Serial.println(F("s."));
      check_timestep = timestep;
      config::measurement_timestep = timestep;
      led_effects::showKITTWheel(color::green, 1);
    }
319
320
321
  }

  void calibrateSensorToSpecificPPM(int32_t calibrationLevel) {
Eric Duminil's avatar
Eric Duminil committed
322
    Serial.println(F("TODO: Implement ME!"));
323
324
325
  }

  void calibrateSensorRightNow(int32_t calibrationLevel) {
Eric Duminil's avatar
Eric Duminil committed
326
327
328
329
330
331
332
    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();
    }
333
  }
334
}