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