|
| 1 | +/*! |
| 2 | + * @file WipperSnapper_I2C_Driver_SGP41.h |
| 3 | + * |
| 4 | + * Device driver for the SGP41 VOC/gas sensor. |
| 5 | + * |
| 6 | + * Adafruit invests time and resources providing this open source code, |
| 7 | + * please support Adafruit and open-source hardware by purchasing |
| 8 | + * products from Adafruit! |
| 9 | + * |
| 10 | + * Copyright (c) Tyeth Gundry 2025 for Adafruit Industries. |
| 11 | + * |
| 12 | + * MIT license, all text here must be included in any redistribution. |
| 13 | + * |
| 14 | + */ |
| 15 | + |
| 16 | +#ifndef WipperSnapper_I2C_Driver_SGP41_H |
| 17 | +#define WipperSnapper_I2C_Driver_SGP41_H |
| 18 | + |
| 19 | +#include "WipperSnapper_I2C_Driver.h" |
| 20 | +#include <Adafruit_SGP41.h> |
| 21 | +#include <NOxGasIndexAlgorithm.h> |
| 22 | +#include <VOCGasIndexAlgorithm.h> |
| 23 | +#include <Wire.h> |
| 24 | + |
| 25 | +#define SGP41_FASTTICK_INTERVAL_MS 1000 ///< Enforce ~1 Hz cadence |
| 26 | +#define SGP41_CONDITIONING_TICKS 10 ///< Recommended warmup cycles |
| 27 | +#define SGP41_VOC_LEARNING_MS 60000UL ///< VOC index meaningful after ~60s |
| 28 | +#define SGP41_NOX_LEARNING_MS 300000UL ///< NOx index meaningful after ~300s |
| 29 | + |
| 30 | +/**************************************************************************/ |
| 31 | +/*! |
| 32 | + @brief Class that provides a driver interface for the SGP41 sensor. |
| 33 | +*/ |
| 34 | +/**************************************************************************/ |
| 35 | +class WipperSnapper_I2C_Driver_SGP41 : public WipperSnapper_I2C_Driver { |
| 36 | +public: |
| 37 | + /*******************************************************************************/ |
| 38 | + /*! |
| 39 | + @brief Constructor for a SGP41 sensor. |
| 40 | + @param i2c |
| 41 | + The I2C interface. |
| 42 | + @param sensorAddress |
| 43 | + 7-bit device address. |
| 44 | + */ |
| 45 | + /*******************************************************************************/ |
| 46 | + WipperSnapper_I2C_Driver_SGP41(TwoWire *i2c, uint16_t sensorAddress) |
| 47 | + : WipperSnapper_I2C_Driver(i2c, sensorAddress) { |
| 48 | + _i2c = i2c; |
| 49 | + _sensorAddress = sensorAddress; |
| 50 | + _sgp41 = nullptr; |
| 51 | + } |
| 52 | + |
| 53 | + /*******************************************************************************/ |
| 54 | + /*! |
| 55 | + @brief Destructor for an SGP41 sensor driver. |
| 56 | + Cleans up and deallocates the underlying Adafruit_SGP41 object |
| 57 | + when the driver is destroyed. |
| 58 | + */ |
| 59 | + /*******************************************************************************/ |
| 60 | + ~WipperSnapper_I2C_Driver_SGP41() { |
| 61 | + if (_sgp41) { |
| 62 | + _sgp41->turnHeaterOff(); |
| 63 | + delete _sgp41; |
| 64 | + _sgp41 = nullptr; |
| 65 | + } |
| 66 | + } |
| 67 | + |
| 68 | + /*******************************************************************************/ |
| 69 | + /*! |
| 70 | + @brief Initializes the SGP41 sensor and begins I2C. |
| 71 | + @returns True if initialized successfully, False otherwise. |
| 72 | + */ |
| 73 | + /*******************************************************************************/ |
| 74 | + bool begin() { |
| 75 | + _sgp41 = new Adafruit_SGP41(); |
| 76 | + if (!_sgp41 || !_sgp41->begin((uint8_t)_sensorAddress, _i2c)) { |
| 77 | + delete _sgp41; |
| 78 | + _sgp41 = nullptr; |
| 79 | + return false; |
| 80 | + } |
| 81 | + |
| 82 | + _sgp41->softReset(); |
| 83 | + |
| 84 | + uint16_t serialNumber[3] = {0, 0, 0}; |
| 85 | + _hasSerial = _sgp41->getSerialNumber(serialNumber); |
| 86 | + if (_hasSerial) { |
| 87 | + _serialNumber[0] = serialNumber[0]; |
| 88 | + _serialNumber[1] = serialNumber[1]; |
| 89 | + _serialNumber[2] = serialNumber[2]; |
| 90 | + } |
| 91 | + |
| 92 | + _selfTestResult = _sgp41->executeSelfTest(); |
| 93 | + |
| 94 | + // Initialize cached values |
| 95 | + _rawValue = 0; |
| 96 | + _rawNOxValue = 0; |
| 97 | + _vocIdx = 0; |
| 98 | + _noxIdx = 0; |
| 99 | + _conditioningTicks = 0; |
| 100 | + _algoStartMs = millis(); |
| 101 | + _lastFastMs = millis() - SGP41_FASTTICK_INTERVAL_MS; |
| 102 | + return true; |
| 103 | + } |
| 104 | + |
| 105 | + /*******************************************************************************/ |
| 106 | + /*! |
| 107 | + @brief Gets the sensor's current raw unprocessed value. |
| 108 | + @param rawEvent |
| 109 | + Pointer to an Adafruit_Sensor event. |
| 110 | + @returns True if the raw value was obtained successfully, False |
| 111 | + otherwise. |
| 112 | + */ |
| 113 | + /*******************************************************************************/ |
| 114 | + bool getEventRaw(sensors_event_t *rawEvent) override { |
| 115 | + if (!_sgp41) |
| 116 | + return false; |
| 117 | + rawEvent->data[0] = (float)_rawValue; |
| 118 | + return true; |
| 119 | + } |
| 120 | + |
| 121 | + /*******************************************************************************/ |
| 122 | + /*! |
| 123 | + @brief Gets the SGP41's current VOC reading. |
| 124 | + @param vocIndexEvent |
| 125 | + Adafruit Sensor event for VOC Index (1-500, 100 is normal) |
| 126 | + @returns True if the sensor value was obtained successfully, False |
| 127 | + otherwise. |
| 128 | + */ |
| 129 | + /*******************************************************************************/ |
| 130 | + bool getEventVOCIndex(sensors_event_t *vocIndexEvent) override { |
| 131 | + if (!_sgp41) |
| 132 | + return false; |
| 133 | + // Note: VOC algorithm learning period is ~60 seconds from startup. |
| 134 | + // Values are valid for publishing immediately, but become meaningful |
| 135 | + // only after this warmup interval. |
| 136 | + vocIndexEvent->voc_index = _vocIdx; |
| 137 | + return true; |
| 138 | + } |
| 139 | + |
| 140 | + /*******************************************************************************/ |
| 141 | + /*! |
| 142 | + @brief Gets the SGP41's current NOx reading. |
| 143 | + @param noxIndexEvent |
| 144 | + Adafruit Sensor event for NOx Index. |
| 145 | + @returns True if the sensor value was obtained successfully, False |
| 146 | + otherwise. |
| 147 | + */ |
| 148 | + /*******************************************************************************/ |
| 149 | + bool getEventNOxIndex(sensors_event_t *noxIndexEvent) override { |
| 150 | + if (!_sgp41) |
| 151 | + return false; |
| 152 | + // Note: NOx algorithm learning period is ~300 seconds from startup. |
| 153 | + // Values are valid for publishing immediately, but become meaningful |
| 154 | + // only after this warmup interval. |
| 155 | + noxIndexEvent->nox_index = _noxIdx; |
| 156 | + return true; |
| 157 | + } |
| 158 | + |
| 159 | + /*******************************************************************************/ |
| 160 | + /*! |
| 161 | + @brief Performs background sampling for the SGP41. |
| 162 | +
|
| 163 | + This method enforces a ~1 Hz cadence recommended by the sensor |
| 164 | + datasheet. On each call, it checks the elapsed time since the last |
| 165 | + poll using `millis()`. If at least SGP41_FASTTICK_INTERVAL_MS ms |
| 166 | + have passed, it reads a new raw value and VOC index from the |
| 167 | + sensor and caches them in `_rawValue` and `_vocIdx`. |
| 168 | +
|
| 169 | + Cached results are later returned by `getEventRaw()` and |
| 170 | + `getEventVOCIndex()` without re-triggering I2C traffic. |
| 171 | +
|
| 172 | + @note Called automatically from |
| 173 | + `WipperSnapper_Component_I2C::update()` once per loop iteration. |
| 174 | + Must be non-blocking (no delays). The millis-based guard ensures |
| 175 | + the sensor is not over-polled. |
| 176 | + */ |
| 177 | + /*******************************************************************************/ |
| 178 | + void fastTick() override { |
| 179 | + if (!_sgp41) |
| 180 | + return; |
| 181 | + if (!gasEnabled()) |
| 182 | + return; |
| 183 | + |
| 184 | + uint32_t now = millis(); |
| 185 | + if (now - _lastFastMs >= SGP41_FASTTICK_INTERVAL_MS) { |
| 186 | + uint16_t srawVoc = 0; |
| 187 | + uint16_t srawNox = 0; |
| 188 | + bool readOk = false; |
| 189 | + |
| 190 | + if (_conditioningTicks < SGP41_CONDITIONING_TICKS) { |
| 191 | + // Conditioning is part of expected SGP41 startup usage. |
| 192 | + // It warms up the VOC sensing path and seeds early baseline behavior. |
| 193 | + // We currently use library defaults (50% RH, 25C) because Wippersnapper |
| 194 | + // does not yet provide reference humidity/temperature feeds to this |
| 195 | + // driver. Future integration point: pass external RH/T references here. |
| 196 | + readOk = _sgp41->executeConditioning(&srawVoc); |
| 197 | + srawNox = 0; |
| 198 | + _conditioningTicks++; |
| 199 | + } else { |
| 200 | + // After conditioning, 1 Hz raw sampling is expected usage for SGP41. |
| 201 | + // This call supports RH/T compensation; we currently keep defaults. |
| 202 | + // Future integration point: call |
| 203 | + // measureRawSignals(&srawVoc, &srawNox, rh, tempC) |
| 204 | + // once reference feeds are available. |
| 205 | + readOk = _sgp41->measureRawSignals(&srawVoc, &srawNox); |
| 206 | + } |
| 207 | + |
| 208 | + if (readOk) { |
| 209 | + _rawValue = srawVoc; |
| 210 | + _rawNOxValue = srawNox; |
| 211 | + |
| 212 | + // Follow Adafruit_SGP41 gas_index example flow: |
| 213 | + // raw ticks -> Sensirion VOC/NOx gas index algorithms. |
| 214 | + _vocIdx = _vocAlgorithm.process((int32_t)srawVoc); |
| 215 | + _noxIdx = _noxAlgorithm.process((int32_t)srawNox); |
| 216 | + |
| 217 | + // Learning-time guidance (from example): |
| 218 | + // VOC becomes meaningful after ~1 minute, NOx after ~5 minutes. |
| 219 | + // Future integration point: expose a quality/status flag once |
| 220 | + // Wippersnapper signal schema has a per-reading readiness field. |
| 221 | + } |
| 222 | + |
| 223 | + _lastFastMs = now; |
| 224 | + } |
| 225 | + } |
| 226 | + |
| 227 | +protected: |
| 228 | + Adafruit_SGP41 *_sgp41; ///< Pointer to SGP41 sensor object |
| 229 | + |
| 230 | + /** |
| 231 | + * Cached latest measurements from the sensor. |
| 232 | + * - _rawValue: raw VOC sensor output (ticks) |
| 233 | + * - _rawNOxValue: raw NOx sensor output (ticks) |
| 234 | + * - _vocIdx: calculated VOC Gas Index |
| 235 | + * - _noxIdx: calculated NOx Gas Index |
| 236 | + */ |
| 237 | + uint16_t _rawValue = 0; ///< Raw VOC sensor output (ticks) |
| 238 | + uint16_t _rawNOxValue = 0; ///< Raw NOx sensor output (ticks) |
| 239 | + float _vocIdx = 0; ///< Calculated VOC Gas Index |
| 240 | + float _noxIdx = 0; ///< Calculated NOx Gas Index |
| 241 | + VOCGasIndexAlgorithm _vocAlgorithm; ///< VOC gas index state machine |
| 242 | + NOxGasIndexAlgorithm _noxAlgorithm; ///< NOx gas index state machine |
| 243 | + uint8_t _conditioningTicks = 0; ///< Completed initial conditioning cycles |
| 244 | + uint32_t _algoStartMs = 0; ///< Timestamp when gas index learning began |
| 245 | + uint16_t _serialNumber[3] = {0, 0, 0}; ///< Optional serial number cache |
| 246 | + uint16_t _selfTestResult = 0; ///< Optional self-test cache |
| 247 | + bool _hasSerial = false; ///< True if serial number read succeeded |
| 248 | + uint32_t _lastFastMs = 0; ///< Last poll timestamp to enforce cadence |
| 249 | + |
| 250 | + /*******************************************************************************/ |
| 251 | + /*! |
| 252 | + @brief Returns whether VOC background sampling should be active. |
| 253 | + @return True if either VOC Index or raw value is configured to publish. |
| 254 | + */ |
| 255 | + /*******************************************************************************/ |
| 256 | + inline bool gasEnabled() { |
| 257 | + return (getSensorVOCIndexPeriod() > 0) || (getSensorNOxIndexPeriod() > 0) || |
| 258 | + (getSensorRawPeriod() > 0); |
| 259 | + } |
| 260 | +}; |
| 261 | + |
| 262 | +#endif // WipperSnapper_I2C_Driver_SGP41_H |
0 commit comments