/ firmware / src / sensors / carbon_dioxide.cpp
carbon_dioxide.cpp
  1  #include "carbon_dioxide.h"
  2  #include "registry.h"
  3  #include <config.h>
  4  #include <i2c.h>
  5  
  6  #include <Arduino.h>
  7  #include <SensirionI2cScd30.h>
  8  #include <SensirionI2cScd4x.h>
  9  
 10  enum Co2Backend { CO2_NONE, CO2_SCD30, CO2_SCD4X };
 11  
 12  static SensirionI2cScd30 scd30;
 13  static SensirionI2cScd4x scd4x;
 14  static Co2Backend backend = CO2_NONE;
 15  static bool measuring = false;
 16  
 17  constexpr uint16_t SCD30_RESET_MS = 2000;
 18  constexpr uint16_t SCD4X_STOP_MEASUREMENT_MS = 500;
 19  constexpr uint8_t SCD4X_COMMAND_MS = 30;
 20  
 21  static uint8_t resolved_bus = 0;
 22  static int8_t resolved_mux_channel = config::i2c::DIRECT_CHANNEL;
 23  
 24  // apply_selection / clearSelection must bracket every I2C op to a mux-behind
 25  // sensor. They do more than route the TCA9548A: accessDevice also gates the
 26  // channel's power rail via MUX_POWER_GPIO_ODD/EVEN (see hardware/i2c.cpp).
 27  // Removing these calls silently cuts power mid-transaction on the next poll.
 28  static void apply_selection(void) {
 29    if (resolved_mux_channel >= 0) {
 30      hardware::i2c::DeviceAccessCommand cmd = {};
 31      cmd.bus = resolved_bus == 0 ? hardware::i2c::Bus::Bus0 : hardware::i2c::Bus::Bus1;
 32      cmd.mux_channel = resolved_mux_channel;
 33      hardware::i2c::accessDevice(&cmd);
 34    }
 35  }
 36  
 37  static bool try_scd30_on(uint8_t bus, uint8_t address, int8_t mux_channel) {
 38    hardware::i2c::DeviceAccessCommand cmd = {};
 39    cmd.bus = bus == 0 ? hardware::i2c::Bus::Bus0 : hardware::i2c::Bus::Bus1;
 40    cmd.mux_channel = mux_channel;
 41    if (!hardware::i2c::accessDevice(&cmd)) return false;
 42  
 43    scd30.begin(*cmd.wire, address);
 44    uint8_t major, minor;
 45    if (scd30.readFirmwareVersion(major, minor) != 0) {
 46      hardware::i2c::clearSelection();
 47      return false;
 48    }
 49    scd30.softReset();
 50    delay(SCD30_RESET_MS);
 51    // SCD30 lacks a single-shot API. On power-gated boards clearSelection() cuts
 52    // the sensor rail, breaking periodic-measurement state persistence.
 53    if (scd30.startPeriodicMeasurement(0) != 0) {
 54      hardware::i2c::clearSelection();
 55      return false;
 56    }
 57    hardware::i2c::clearSelection();
 58    Serial.printf("[co2] SCD30 detected on bus %d (fw %d.%d)\n", bus, major, minor);
 59    return true;
 60  }
 61  
 62  static bool try_scd4x_on(uint8_t bus, uint8_t address, int8_t mux_channel) {
 63    hardware::i2c::DeviceAccessCommand cmd = {};
 64    cmd.bus = bus == 0 ? hardware::i2c::Bus::Bus0 : hardware::i2c::Bus::Bus1;
 65    cmd.mux_channel = mux_channel;
 66    if (!hardware::i2c::accessDevice(&cmd)) return false;
 67  
 68    scd4x.begin(*cmd.wire, address);
 69    scd4x.wakeUp();
 70    delay(SCD4X_COMMAND_MS);
 71    scd4x.stopPeriodicMeasurement();
 72    delay(SCD4X_STOP_MEASUREMENT_MS);
 73    scd4x.reinit();
 74    delay(SCD4X_COMMAND_MS);
 75  
 76    uint64_t serialNumber = 0;
 77    if (scd4x.getSerialNumber(serialNumber) != 0) {
 78      hardware::i2c::clearSelection();
 79      return false;
 80    }
 81    // Do NOT call startPeriodicMeasurement here. Board power gating cuts the
 82    // sensor rail at clearSelection() below, wiping any measurement state. SCD41
 83    // uses measureAndReadSingleShot() in access() instead.
 84    hardware::i2c::clearSelection();
 85    Serial.printf("[co2] SCD41 detected on bus %d (serial 0x%08lX%08lX)\n",
 86                  bus, (uint32_t)(serialNumber >> 32), (uint32_t)(serialNumber & 0xFFFFFFFF));
 87    return true;
 88  }
 89  
 90  bool sensors::carbon_dioxide::initialize() {
 91    backend = CO2_NONE;
 92    resolved_mux_channel = config::i2c::DIRECT_CHANNEL;
 93  
 94    hardware::i2c::DiscoveredDevice dev = {};
 95  
 96    if (hardware::i2c::findDevice(0x61, &dev) && try_scd30_on(dev.bus, dev.address, dev.mux_channel)) {
 97      backend = CO2_SCD30;
 98      resolved_bus = dev.bus;
 99      resolved_mux_channel = dev.mux_channel;
100      measuring = true;
101    } else if (hardware::i2c::findDevice(0x62, &dev) && try_scd4x_on(dev.bus, dev.address, dev.mux_channel)) {
102      backend = CO2_SCD4X;
103      resolved_bus = dev.bus;
104      resolved_mux_channel = dev.mux_channel;
105      measuring = true;
106    }
107  
108    if (backend == CO2_NONE) {
109      Serial.println(F("[co2] no sensor found"));
110      return false;
111    }
112  
113    sensors::registry::add({
114        .kind = SensorKind::CarbonDioxide,
115        .name = "Carbon Dioxide",
116        .isAvailable = sensors::carbon_dioxide::isAvailable,
117        .instanceCount = []() -> uint8_t { return 1; },
118        .poll = [](uint8_t, void *out, size_t cap) -> bool {
119            if (cap < sizeof(CO2SensorData)) return false;
120            return sensors::carbon_dioxide::access(static_cast<CO2SensorData *>(out));
121        },
122        .data_size = sizeof(CO2SensorData),
123    });
124    return true;
125  }
126  
127  bool sensors::carbon_dioxide::access(CO2SensorData *sensor_data) {
128    if (!sensor_data) return false;
129  
130    if (backend == CO2_SCD30) {
131      sensor_data->model = "SCD30";
132      apply_selection();
133      uint16_t ready = 0;
134      scd30.getDataReady(ready);
135      if (!ready) {
136        hardware::i2c::clearSelection();
137        sensor_data->ok = false;
138        return false;
139      }
140  
141      float co2, temp, hum;
142      if (scd30.readMeasurementData(co2, temp, hum) == 0) {
143        hardware::i2c::clearSelection();
144        sensor_data->co2_ppm = co2;
145        sensor_data->temperature_celsius = temp;
146        sensor_data->relative_humidity_percent = hum;
147        sensor_data->ok = true;
148        return true;
149      }
150      hardware::i2c::clearSelection();
151      sensor_data->ok = false;
152      return false;
153    }
154  
155    if (backend == CO2_SCD4X) {
156      // measureAndReadSingleShot blocks ~5s. This is a hardware constraint:
157      // power is cut between polls, so we cannot split into fire-and-forget
158      // + read-later — the sensor needs continuous power through the full
159      // measurement window.
160      sensor_data->model = "SCD41";
161      apply_selection();
162      uint16_t co2 = 0;
163      float temp = 0.0f, hum = 0.0f;
164      int16_t rc = scd4x.measureAndReadSingleShot(co2, temp, hum);
165      hardware::i2c::clearSelection();
166      if (rc == 0) {
167        sensor_data->co2_ppm = (float)co2;
168        sensor_data->temperature_celsius = temp;
169        sensor_data->relative_humidity_percent = hum;
170        sensor_data->ok = true;
171        return true;
172      }
173      sensor_data->ok = false;
174      return false;
175    }
176  
177    sensor_data->ok = false;
178    sensor_data->model = "none";
179    return false;
180  }
181  
182  bool sensors::carbon_dioxide::enable() {
183    if (backend == CO2_NONE) return false;
184    // SCD4X runs in single-shot mode (see access()), so there is no persistent
185    // hardware state to start. `measuring` is pure UI-layer bookkeeping.
186    if (backend == CO2_SCD4X) {
187      measuring = true;
188      return true;
189    }
190    apply_selection();
191    bool ok = scd30.startPeriodicMeasurement(0) == 0;
192    hardware::i2c::clearSelection();
193    if (ok) measuring = true;
194    return ok;
195  }
196  
197  bool sensors::carbon_dioxide::disable() {
198    if (backend == CO2_NONE) return false;
199    // See enable(): SCD4X single-shot has no persistent state.
200    if (backend == CO2_SCD4X) {
201      measuring = false;
202      return true;
203    }
204    apply_selection();
205    bool ok = scd30.stopPeriodicMeasurement() == 0;
206    hardware::i2c::clearSelection();
207    if (ok) measuring = false;
208    return ok;
209  }
210  
211  bool sensors::carbon_dioxide::accessConfig(Co2Config *config) {
212    if (!config) return false;
213  
214    config->measuring = measuring;
215    config->measurement_interval_seconds = 0;
216    config->auto_calibration_enabled = false;
217    config->temperature_offset_celsius = 0.0f;
218    config->altitude_meters = 0;
219  
220    if (backend == CO2_SCD30) {
221      config->model = "SCD30";
222      apply_selection();
223      uint16_t interval;
224      if (scd30.getMeasurementInterval(interval) == 0)
225        config->measurement_interval_seconds = interval;
226      uint16_t asc;
227      if (scd30.getAutoCalibrationStatus(asc) == 0)
228        config->auto_calibration_enabled = (asc != 0);
229      uint16_t offset;
230      if (scd30.getTemperatureOffset(offset) == 0)
231        config->temperature_offset_celsius = offset / 100.0f;
232      uint16_t alt;
233      if (scd30.getAltitudeCompensation(alt) == 0)
234        config->altitude_meters = alt;
235      hardware::i2c::clearSelection();
236      return true;
237    }
238  
239    if (backend == CO2_SCD4X) {
240      config->model = "SCD41";
241      config->measurement_interval_seconds = 5;
242      return true;
243    }
244  
245    config->model = "none";
246    return false;
247  }
248  
249  bool sensors::carbon_dioxide::configureInterval(uint16_t seconds) {
250    if (backend != CO2_SCD30) return false;
251    apply_selection();
252    bool ok = scd30.setMeasurementInterval(seconds) == 0;
253    hardware::i2c::clearSelection();
254    return ok;
255  }
256  
257  bool sensors::carbon_dioxide::configureAutoCalibration(bool enabled) {
258    if (backend != CO2_SCD30) return false;
259    apply_selection();
260    bool ok = scd30.activateAutoCalibration(enabled ? 1 : 0) == 0;
261    hardware::i2c::clearSelection();
262    return ok;
263  }
264  
265  bool sensors::carbon_dioxide::configureTemperatureOffset(float celsius) {
266    if (backend != CO2_SCD30) return false;
267    apply_selection();
268    bool ok = scd30.setTemperatureOffset((uint16_t)(celsius * 100)) == 0;
269    hardware::i2c::clearSelection();
270    return ok;
271  }
272  
273  bool sensors::carbon_dioxide::configureAltitude(uint16_t meters) {
274    if (backend != CO2_SCD30) return false;
275    apply_selection();
276    bool ok = scd30.setAltitudeCompensation(meters) == 0;
277    hardware::i2c::clearSelection();
278    return ok;
279  }
280  
281  bool sensors::carbon_dioxide::configureRecalibration(uint16_t co2_reference_ppm) {
282    if (backend != CO2_SCD30) return false;
283    apply_selection();
284    bool ok = scd30.forceRecalibration(co2_reference_ppm) == 0;
285    hardware::i2c::clearSelection();
286    return ok;
287  }
288  
289  bool sensors::carbon_dioxide::isAvailable() {
290    return backend != CO2_NONE;
291  }
292  
293  #ifdef PIO_UNIT_TESTING
294  
295  #include <testing/utils.h>
296  
297  static void co2_module_initializes(void) {
298    GIVEN("the I2C bus and TCA9548A mux are initialized");
299    WHEN("initialize() probes 0x61 (SCD30) and 0x62 (SCD41) via findDevice");
300    THEN("a supported CO2 sensor is detected and registered");
301    hardware::i2c::initialize();
302    TEST_ASSERT_TRUE_MESSAGE(sensors::carbon_dioxide::initialize(),
303      "device: CO2 module initialization failed");
304  }
305  
306  static void sensor_backend_is_detected(void) {
307    GIVEN("initialize() has detected a CO2 sensor (backend != CO2_NONE)");
308    WHEN("accessConfig() is called");
309    THEN("config.model reports the detected backend (SCD30 or SCD41)");
310    if (!sensors::carbon_dioxide::isAvailable()) {
311      TEST_IGNORE_MESSAGE("no CO2 sensor connected");
312      return;
313    }
314    Co2Config config;
315    sensors::carbon_dioxide::accessConfig(&config);
316    TEST_PRINTF("detected: %s", config.model);
317  }
318  
319  static void co2_reading_is_non_zero_ppm(void) {
320    GIVEN("an SCD41 is registered in the sensor registry");
321    WHEN("access() triggers a single-shot measurement (~5s blocking)");
322    THEN("sensor_data.co2_ppm > 0 and model is \"SCD41\"");
323    if (!sensors::carbon_dioxide::isAvailable()) {
324      TEST_IGNORE_MESSAGE("no CO2 sensor available");
325      return;
326    }
327    CO2SensorData sensor_data = {};
328    bool ok = sensors::carbon_dioxide::access(&sensor_data);
329    if (!ok) {
330      TEST_IGNORE_MESSAGE("read not ready yet");
331      return;
332    }
333    char msg[128];
334    snprintf(msg, sizeof(msg), "%s: %.1f ppm, %.1f C, %.1f %%",
335             sensor_data.model, sensor_data.co2_ppm, sensor_data.temperature_celsius,
336             sensor_data.relative_humidity_percent);
337    TEST_MESSAGE(msg);
338    TEST_ASSERT_FLOAT_IS_DETERMINATE_MESSAGE(sensor_data.co2_ppm,
339      "device: CO2 reading is NaN or Inf");
340    TEST_ASSERT_GREATER_THAN_FLOAT_MESSAGE(0.0f, sensor_data.co2_ppm,
341      "device: CO2 reading must be > 0 ppm");
342  }
343  
344  void sensors::carbon_dioxide::test() {
345    MODULE("CO2");
346    RUN_TEST(co2_module_initializes);
347    RUN_TEST(sensor_backend_is_detected);
348    RUN_TEST(co2_reading_is_non_zero_ppm);
349  }
350  
351  #endif