diff --git a/examples/UARTRead/UARTRead.ino b/examples/UARTRead/UARTRead.ino index dfbfe71..cd84201 100644 --- a/examples/UARTRead/UARTRead.ino +++ b/examples/UARTRead/UARTRead.ino @@ -26,153 +26,112 @@ * */ -#include -#include -#include - -constexpr char DEFAULT_DELIMITER = ','; - -std::map> csvFieldMapping = { - {0, {"HS4001 sample counter", "uint32"}}, - {1, {"HS4001 temperature (degC)", "float"}}, - {2, {"HS4001 humidity (%RH)", "float"}}, - {3, {"ZMOD4510 status", "uint8"}}, - {4, {"ZMOD4510 sample counter", "uint32"}}, - {5, {"ZMOD4510 EPA AQI", "uint16"}}, - {6, {"ZMOD4510 Fast AQI", "uint16"}}, - {7, {"ZMOD4510 O3 (ppb)", "float"}}, - {8, {"ZMOD4510 NO2 (ppb)", "float"}}, - {9, {"ZMOD4510 Rmox[0]", "float"}}, - {10, {"ZMOD4510 Rmox[1]", "float"}}, - {11, {"ZMOD4510 Rmox[2]", "float"}}, - {12, {"ZMOD4510 Rmox[3]", "float"}}, - {13, {"ZMOD4510 Rmox[4]", "float"}}, - {14, {"ZMOD4510 Rmox[5]", "float"}}, - {15, {"ZMOD4510 Rmox[6]", "float"}}, - {16, {"ZMOD4510 Rmox[7]", "float"}}, - {17, {"ZMOD4510 Rmox[8]", "float"}}, - {18, {"ZMOD4510 Rmox[9]", "float"}}, - {19, {"ZMOD4510 Rmox[10]", "float"}}, - {20, {"ZMOD4510 Rmox[11]", "float"}}, - {21, {"ZMOD4510 Rmox[12]", "float"}}, - {22, {"ZMOD4410 status", "uint8"}}, - {23, {"ZMD4410 sample counter", "uint32"}}, - {24, {"ZMOD4410 IAQ", "float"}}, - {25, {"ZMOD4410 TVOC (mg/m^3)", "float"}}, - {26, {"ZMOD4410 eCO2 (ppm)", "float"}}, - {27, {"ZMOD4410 Rel IAQ", "float"}}, - {28, {"ZMOD4410 EtOH (ppm)", "float"}}, - {29, {"ZMOD4410 Rmox[0]", "float"}}, - {30, {"ZMOD4410 Rmox[1]", "float"}}, - {31, {"ZMOD4410 Rmox[2]", "float"}}, - {32, {"ZMOD4410 Rmox[3]", "float"}}, - {33, {"ZMOD4410 Rmox[4]", "float"}}, - {34, {"ZMOD4410 Rmox[5]", "float"}}, - {35, {"ZMOD4410 Rmox[6]", "float"}}, - {36, {"ZMOD4410 Rmox[7]", "float"}}, - {37, {"ZMOD4410 Rmox[8]", "float"}}, - {38, {"ZMOD4410 Rmox[9]", "float"}}, - {39, {"ZMOD4410 Rmox[10]", "float"}}, - {40, {"ZMOD4410 Rmox[11]", "float"}}, - {41, {"ZMOD4410 Rmox[12]", "float"}}, - {42, {"ZMOD4410 Rcda[0]", "float"}}, - {43, {"ZMOD4410 Rcda[1]", "float"}}, - {44, {"ZMOD4410 Rcda[2]", "float"}}, - {45, {"ZMOD4410 Rhtr", "float"}}, - {46, {"ZMOD4410 Temp", "float"}}, - {47, {"ZMOD4410 intensity", "float"}}, - {48, {"ZMOD4410 odor", "uint8"}} -}; - -std::map parsedValuesMap; - -// Function to convert a string to a float, handling exponents -float parseFloatWithExponent(const String &str) { - // Convert the string to a double - double value = str.toDouble(); - - // Convert the double to a float - return static_cast(value); -} +#include "NiclaSenseEnvSerial.h" -// Function to process a CSV line -void processCSVLine(String data, char delimiter, std::map &targetMap) { - // Skip lines that start with INFO: or WARNING: - if (data.startsWith("INFO:") || data.startsWith("WARNING:")) { - return; +NiclaSenseEnvSerial niclaSerial(Serial1); + +void setup() { + Serial.begin(115200); + niclaSerial.begin(); + + while (!Serial) { + delay(100); } - // Print the error message if the line starts with ERROR: - if (data.startsWith("ERROR:")) { - Serial.println(data); + Serial.println("Serial ports initialized"); +} + +void loop() { + bool updated = niclaSerial.update(); + + if (!updated) { + String err = niclaSerial.lastErrorMessage(); + if (err.length() > 0) { + Serial.print("Error: "); + Serial.println(err); + } + delay(100); return; } - // Split CSV line into fields - std::vector fields; - size_t pos = 0; - while ((pos = data.indexOf(delimiter)) != -1) { - fields.push_back(data.substring(0, pos)); - data = data.substring(pos + 1); + float temperature = niclaSerial.temperature(); + if (!isnan(temperature)) { + Serial.print("🌡 Temperature (°C): "); + Serial.println(temperature); } - fields.push_back(data); // Last field - // Map fields to their corresponding names and store in parsedValuesMap - for (size_t i = 0; i < fields.size(); ++i) { - // Use index as key to get tuple (name, type) - auto [name, type] = csvFieldMapping[i]; - String fieldValue = fields[i]; + float humidity = niclaSerial.humidity(); + if (!isnan(humidity)) { + Serial.print("💧 Humidity (%RH): "); + Serial.println(humidity); + } - // Check if the field is empty - if (fieldValue == "") { - continue; - } + int epaAqi = niclaSerial.outdoorAirQualityIndex(); + if (epaAqi >= 0) { + Serial.print("🏭 Outdoor EPA AQI: "); + Serial.println(epaAqi); + Serial.print("🏭 Outdoor EPA AQI interpreted: "); + Serial.println(niclaSerial.outdoorAirQualityIndexInterpreted()); + } - // Check if the field is a float based on the "type" property - if (type == "float") { - float floatValue = parseFloatWithExponent(fieldValue); - targetMap[name] = String(floatValue); - } else { - targetMap[name] = fieldValue; - } + int fastAqi = niclaSerial.outdoorFastAirQualityIndex(); + if (fastAqi >= 0) { + Serial.print("🏭 Outdoor Fast AQI: "); + Serial.println(fastAqi); } -} -void setup(){ - Serial.begin(115200); - Serial1.begin(38400, SERIAL_8N1); + float o3 = niclaSerial.O3(); + if (!isnan(o3)) { + Serial.print("🌬 Outdoor O3 (ppb): "); + Serial.println(o3); + } - while (!Serial || !Serial1) { - delay(100); + float no2 = niclaSerial.NO2(); + if (!isnan(no2)) { + Serial.print("🌬 Outdoor NO2 (ppb): "); + Serial.println(no2); } - Serial.println("Serial ports initialized"); -} + float iaq = niclaSerial.indoorAirQuality(); + if (!isnan(iaq)) { + Serial.print("🏠 Indoor IAQ: "); + Serial.println(iaq); + Serial.print("🏠 Indoor IAQ interpreted: "); + Serial.println(niclaSerial.indoorAirQualityInterpreted()); + } + float relIaq = niclaSerial.indoorRelativeAirQuality(); + if (!isnan(relIaq)) { + Serial.print("🏠 Indoor relative IAQ: "); + Serial.println(relIaq); + } -void loop() { - if (!Serial1.available()) { - delay(100); - return; + float co2 = niclaSerial.CO2(); + if (!isnan(co2)) { + Serial.print("🌬 Indoor eCO2 (ppm): "); + Serial.println(co2); } - String csvLine = Serial1.readStringUntil('\n'); - processCSVLine(csvLine, DEFAULT_DELIMITER, parsedValuesMap); + float tvoc = niclaSerial.TVOC(); + if (!isnan(tvoc)) { + Serial.print("🌬 Indoor TVOC (mg/m^3): "); + Serial.println(tvoc); + } - // If map is empty, there was no data to parse - if (parsedValuesMap.empty()) { - Serial.println("No data to parse."); - return; + float ethanol = niclaSerial.ethanol(); + if (!isnan(ethanol)) { + Serial.print("🍺 Ethanol (ppm): "); + Serial.println(ethanol); } - // Print parsed values in the loop - for (const auto &entry : parsedValuesMap) { - Serial.print(entry.first + ": "); - Serial.println(entry.second); + float odorIntensity = niclaSerial.odorIntensity(); + if (!isnan(odorIntensity)) { + Serial.print("👃 Odor intensity: "); + Serial.println(odorIntensity); } - Serial.println(); + Serial.print("👃 Sulfur odor: "); + Serial.println(niclaSerial.sulfurOdor() ? "detected" : "not detected"); - // Clear the map for the next iteration - parsedValuesMap.clear(); + Serial.println(); } \ No newline at end of file diff --git a/src/NiclaSenseEnvSerial.cpp b/src/NiclaSenseEnvSerial.cpp new file mode 100644 index 0000000..a60a0e1 --- /dev/null +++ b/src/NiclaSenseEnvSerial.cpp @@ -0,0 +1,239 @@ +#include "NiclaSenseEnvSerial.h" + +/* CSV field definitions +Index, Description, Type +------------------------------------ +0, "HS4001 sample counter", "uint32" +1, "HS4001 temperature (degC)", "float" +2, "HS4001 humidity (%RH)", "float" +3, "ZMOD4510 status", "uint8" +4, "ZMOD4510 sample counter", "uint32" +5, "ZMOD4510 EPA AQI", "uint16" +6, "ZMOD4510 Fast AQI", "uint16" +7, "ZMOD4510 O3 (ppb)", "float" +8, "ZMOD4510 NO2 (ppb)", "float" +9, "ZMOD4510 Rmox[0]", "float" +10, "ZMOD4510 Rmox[1]", "float" +11, "ZMOD4510 Rmox[2]", "float" +12, "ZMOD4510 Rmox[3]", "float" +13, "ZMOD4510 Rmox[4]", "float" +14, "ZMOD4510 Rmox[5]", "float" +15, "ZMOD4510 Rmox[6]", "float" +16, "ZMOD4510 Rmox[7]", "float" +17, "ZMOD4510 Rmox[8]", "float" +18, "ZMOD4510 Rmox[9]", "float" +19, "ZMOD4510 Rmox[10]", "float" +20, "ZMOD4510 Rmox[11]", "float" +21, "ZMOD4510 Rmox[12]", "float" +22, "ZMOD4410 status", "uint8" +23, "ZMD4410 sample counter", "uint32" +24, "ZMOD4410 IAQ", "float" +25, "ZMOD4410 TVOC (mg/m^3)", "float" +26, "ZMOD4410 eCO2 (ppm)", "float" +27, "ZMOD4410 Rel IAQ", "float" +28, "ZMOD4410 EtOH (ppm)", "float" +29, "ZMOD4410 Rmox[0]", "float" +30, "ZMOD4410 Rmox[1]", "float" +31, "ZMOD4410 Rmox[2]", "float" +32, "ZMOD4410 Rmox[3]", "float" +33, "ZMOD4410 Rmox[4]", "float" +34, "ZMOD4410 Rmox[5]", "float" +35, "ZMOD4410 Rmox[6]", "float" +36, "ZMOD4410 Rmox[7]", "float" +37, "ZMOD4410 Rmox[8]", "float" +38, "ZMOD4410 Rmox[9]", "float" +39, "ZMOD4410 Rmox[10]", "float" +40, "ZMOD4410 Rmox[11]", "float" +41, "ZMOD4410 Rmox[12]", "float" +42, "ZMOD4410 Rcda[0]", "float" +43, "ZMOD4410 Rcda[1]", "float" +44, "ZMOD4410 Rcda[2]", "float" +45, "ZMOD4410 Rhtr", "float" +46, "ZMOD4410 Temp", "float" +47, "ZMOD4410 intensity", "float" +48, "ZMOD4410 odor", "uint8" +*/ + +namespace { +constexpr size_t IDX_TEMPERATURE = 1; +constexpr size_t IDX_HUMIDITY = 2; +constexpr size_t IDX_EPA_AQI = 5; +constexpr size_t IDX_FAST_AQI = 6; +constexpr size_t IDX_O3 = 7; +constexpr size_t IDX_NO2 = 8; +constexpr size_t IDX_IAQ = 24; +constexpr size_t IDX_TVOC = 25; +constexpr size_t IDX_CO2 = 26; +constexpr size_t IDX_REL_IAQ = 27; +constexpr size_t IDX_ETHANOL = 28; +constexpr size_t IDX_ODOR_INTENSITY = 47; +constexpr size_t IDX_SULFUR_ODOR = 48; +} + +// Static lookup tables for CSV field parsing +const NiclaSenseEnvSerial::FieldMapping NiclaSenseEnvSerial::floatFieldMappings[] = { + {IDX_TEMPERATURE, &NiclaSenseEnvSerial::_temperature}, + {IDX_HUMIDITY, &NiclaSenseEnvSerial::_humidity}, + {IDX_O3, &NiclaSenseEnvSerial::_o3}, + {IDX_NO2, &NiclaSenseEnvSerial::_no2}, + {IDX_IAQ, &NiclaSenseEnvSerial::_iaq}, + {IDX_REL_IAQ, &NiclaSenseEnvSerial::_relativeIaq}, + {IDX_CO2, &NiclaSenseEnvSerial::_co2}, + {IDX_TVOC, &NiclaSenseEnvSerial::_tvoc}, + {IDX_ETHANOL, &NiclaSenseEnvSerial::_ethanol}, + {IDX_ODOR_INTENSITY, &NiclaSenseEnvSerial::_odorIntensity}, +}; + +const NiclaSenseEnvSerial::FieldMapping NiclaSenseEnvSerial::intFieldMappings[] = { + {IDX_EPA_AQI, &NiclaSenseEnvSerial::_epaAqi}, + {IDX_FAST_AQI, &NiclaSenseEnvSerial::_fastAqi}, +}; + +NiclaSenseEnvSerial::NiclaSenseEnvSerial(HardwareSerial &serialPort) : _serial(&serialPort) {} + +void NiclaSenseEnvSerial::begin(uint32_t baudRate, uint32_t config) { + if (_serial == nullptr) { + return; + } + _serial->begin(baudRate, config); + while (!_serial) { + delay(100); + } +} + +bool NiclaSenseEnvSerial::update() { + _hasNewData = false; + _lastErrorMessage = ""; + if (_serial == nullptr || !_serial->available()) { + return false; + } + + String csvLine = _serial->readStringUntil('\n'); + processCSVLine(csvLine); + return _hasNewData; +} + +float NiclaSenseEnvSerial::temperature() const { return _temperature; } +float NiclaSenseEnvSerial::humidity() const { return _humidity; } + +int NiclaSenseEnvSerial::outdoorAirQualityIndex() const { return _epaAqi; } +int NiclaSenseEnvSerial::outdoorFastAirQualityIndex() const { return _fastAqi; } +float NiclaSenseEnvSerial::NO2() const { return _no2; } +float NiclaSenseEnvSerial::O3() const { return _o3; } + +String NiclaSenseEnvSerial::outdoorAirQualityIndexInterpreted() const { + int airQualityValue = outdoorAirQualityIndex(); + if (airQualityValue < 0) { + return String("unknown"); + } + if (airQualityValue <= 50) { + return "Good"; + } else if (airQualityValue <= 100) { + return "Moderate"; + } else if (airQualityValue <= 150) { + return "Unhealthy for Sensitive Groups"; + } else if (airQualityValue <= 200) { + return "Unhealthy"; + } else if (airQualityValue <= 300) { + return "Very Unhealthy"; + } else { + return "Hazardous"; + } +} + +float NiclaSenseEnvSerial::indoorAirQuality() const { return _iaq; } + +String NiclaSenseEnvSerial::indoorAirQualityInterpreted() const { + float iaqValue = indoorAirQuality(); + if (isnan(iaqValue)) { + return String("unknown"); + } + if (iaqValue <= 1.99f) { + return "Very Good"; + } else if (iaqValue <= 2.99f) { + return "Good"; + } else if (iaqValue <= 3.99f) { + return "Medium"; + } else if (iaqValue <= 4.99f) { + return "Poor"; + } else { + return "Bad"; + } +} + +float NiclaSenseEnvSerial::indoorRelativeAirQuality() const { return _relativeIaq; } +float NiclaSenseEnvSerial::CO2() const { return _co2; } +float NiclaSenseEnvSerial::TVOC() const { return _tvoc; } +float NiclaSenseEnvSerial::ethanol() const { return _ethanol; } +float NiclaSenseEnvSerial::odorIntensity() const { return _odorIntensity; } +bool NiclaSenseEnvSerial::sulfurOdor() const { return _sulfurOdor; } + +String NiclaSenseEnvSerial::lastErrorMessage() const { return _lastErrorMessage; } + +void NiclaSenseEnvSerial::processCSVLine(String data) { + if (data.length() == 0) { + return; + } + + if (data.startsWith("INFO:") || data.startsWith("WARNING:")) { + return; // Informational messages are ignored + } + + if (data.startsWith("ERROR:")) { + _lastErrorMessage = data; + return; + } + + auto fields = splitFields(data); + + for (const auto &mapping : floatFieldMappings) { + if (fields[mapping.idx].length()) { + setFloatField(this->*mapping.member, fields[mapping.idx]); + } + } + + for (const auto &mapping : intFieldMappings) { + if (fields[mapping.idx].length()) { + setIntField(this->*mapping.member, fields[mapping.idx]); + } + } + + if (fields[IDX_SULFUR_ODOR].length()) { + int odorFlag = static_cast(fields[IDX_SULFUR_ODOR].toInt()); + _sulfurOdor = odorFlag != 0; + _hasNewData = true; + } +} + +std::array NiclaSenseEnvSerial::splitFields(String data) { + std::array fields; + size_t idx = 0; + while (idx < NiclaSenseEnvSerial::CSV_FIELD_COUNT - 1) { + int delimiterPos = data.indexOf(_delimiter); + if (delimiterPos < 0) { + break; + } + fields[idx++] = data.substring(0, delimiterPos); + data = data.substring(delimiterPos + 1); + } + if (idx < NiclaSenseEnvSerial::CSV_FIELD_COUNT) { + fields[idx] = data; + } + return fields; +} + +void NiclaSenseEnvSerial::setFloatField(float &field, const String &value) { + if (value.length() == 0) { + return; + } + field = static_cast(value.toDouble()); + _hasNewData = true; +} + +void NiclaSenseEnvSerial::setIntField(int &field, const String &value) { + if (value.length() == 0) { + return; + } + field = static_cast(value.toInt()); + _hasNewData = true; +} diff --git a/src/NiclaSenseEnvSerial.h b/src/NiclaSenseEnvSerial.h new file mode 100644 index 0000000..dee8489 --- /dev/null +++ b/src/NiclaSenseEnvSerial.h @@ -0,0 +1,176 @@ +#ifndef NICLA_SENSE_ENV_SERIAL_H +#define NICLA_SENSE_ENV_SERIAL_H + +#include +#include + +/** + * @brief Parses UART CSV output from Nicla Sense Env and exposes the same readings + * available through the I2C sensor helper classes. + */ +class NiclaSenseEnvSerial { +public: + /** + * @brief Constructs a NiclaSenseEnvSerial parser bound to a UART interface. + * @param serialPort HardwareSerial instance to read CSV data from. + */ + explicit NiclaSenseEnvSerial(HardwareSerial &serialPort); + + /** + * @brief Initialize the UART reader. + * @param baudRate UART baud rate (defaults to 38400). + * @param config Serial configuration (defaults to SERIAL_8N1). + */ + void begin(uint32_t baudRate = 38400, uint32_t config = SERIAL_8N1); + + /** + * @brief Poll the UART port and parse a CSV line when available. + * @return true when new data was parsed during this call. + */ + bool update(); + + // TemperatureHumiditySensor compatible API + /** + * @brief Get the temperature value from the sensor in degrees Celsius. + * @return Temperature in degrees Celsius, or NAN when unavailable. + */ + float temperature() const; + /** + * @brief Get the relative humidity value. + * @return Relative humidity percentage in the range 0-100, or NAN when unavailable. + */ + float humidity() const; + + // OutdoorAirQualitySensor compatible API + /** + * @brief Retrieves the outdoor EPA air quality index. + * @return AQI value in the range 0-500, or -1 when unavailable. + */ + int outdoorAirQualityIndex() const; + /** + * @brief Get the outdoor fast air quality index (1-minute averaging). + * @return Fast AQI value in the range 0-500, or -1 when unavailable. + */ + int outdoorFastAirQualityIndex() const; + /** + * @brief Get the NO2 value from the outdoor air quality sensor. + * @return Nitrogen dioxide concentration in ppb, or NAN when unavailable. + */ + float NO2() const; + /** + * @brief Get the O3 value from the outdoor air quality sensor. + * @return Ozone concentration in ppb, or NAN when unavailable. + */ + float O3() const; + /** + * @brief Interprets the outdoor EPA AQI into a textual description. + * @return Human-readable AQI category (e.g., Good, Moderate) or "unknown" when unavailable. + */ + String outdoorAirQualityIndexInterpreted() const; + + // IndoorAirQualitySensor compatible API + /** + * @brief Get the indoor air quality value. + * @return IAQ value (common range 0 to ~5), or NAN when unavailable. + */ + float indoorAirQuality() const; + /** + * @brief Get the interpreted indoor air quality value. + * @return Human-readable IAQ category (Very Good, Good, Medium, Poor, Bad) or "unknown" when unavailable. + */ + String indoorAirQualityInterpreted() const; + /** + * @brief Get the indoor relative air quality value. + * @return Relative IAQ percentage in the range 0-100, or NAN when unavailable. + */ + float indoorRelativeAirQuality() const; + + /** + * @brief Get the CO2 value. + * @return Estimated CO2 concentration in ppm, or NAN when unavailable. + */ + float CO2() const; + /** + * @brief Get the TVOC value. + * @return Total volatile organic compounds concentration in mg/m^3, or NAN when unavailable. + */ + float TVOC() const; + /** + * @brief Get the ethanol value. + * @return Ethanol concentration in ppm, or NAN when unavailable. + */ + float ethanol() const; + /** + * @brief Get the odor intensity value. + * @return Odor intensity (sensor-specific scale), or NAN when unavailable. + */ + float odorIntensity() const; + /** + * @brief Get the sulfur odor-detected flag. + * @return true when sulfur odor is detected, false otherwise. + */ + bool sulfurOdor() const; + + /** + * @brief Returns the last error message received over UART, empty when none. + * @return Error string from the device, or an empty String when no error is present. + */ + String lastErrorMessage() const; + +private: + static constexpr size_t CSV_FIELD_COUNT = 49; + + /** + * @brief Process one CSV line, mapping fields into cached values. + * @param data The raw CSV line. + */ + void processCSVLine(String data); + /** + * @brief Split a CSV line into fields using the configured delimiter. + * @param data Raw CSV line. + * @return Fixed-size array of CSV fields. + */ + std::array splitFields(String data); + /** + * @brief Parse and store a float field, marking data as updated when present. + * @param field Destination variable. + * @param value Raw string value to parse. + */ + void setFloatField(float &field, const String &value); + /** + * @brief Parse and store an integer field, marking data as updated when present. + * @param field Destination variable. + * @param value Raw string value to parse. + */ + void setIntField(int &field, const String &value); + + template + struct FieldMapping { + size_t idx; + T NiclaSenseEnvSerial::*member; + }; + + static const FieldMapping floatFieldMappings[]; + static const FieldMapping intFieldMappings[]; + + HardwareSerial *_serial = nullptr; + char _delimiter = ','; + bool _hasNewData = false; + String _lastErrorMessage; + + float _temperature = NAN; + float _humidity = NAN; + int _epaAqi = -1; + int _fastAqi = -1; + float _no2 = NAN; + float _o3 = NAN; + float _iaq = NAN; + float _relativeIaq = NAN; + float _co2 = NAN; + float _tvoc = NAN; + float _ethanol = NAN; + float _odorIntensity = NAN; + bool _sulfurOdor = false; +}; + +#endif