From 9214f9f33e104ce2f9727cc8dcbd5c41c9aa64c8 Mon Sep 17 00:00:00 2001 From: Sebastian Romero Date: Thu, 18 Dec 2025 12:44:33 +0100 Subject: [PATCH 1/4] Encapsulate parsing into class --- examples/UARTRead/UARTRead.ino | 207 +++++++++++----------------- src/NiclaSenseEnvSerial.cpp | 244 +++++++++++++++++++++++++++++++++ src/NiclaSenseEnvSerial.h | 120 ++++++++++++++++ 3 files changed, 447 insertions(+), 124 deletions(-) create mode 100644 src/NiclaSenseEnvSerial.cpp create mode 100644 src/NiclaSenseEnvSerial.h diff --git a/examples/UARTRead/UARTRead.ino b/examples/UARTRead/UARTRead.ino index dfbfe71..74fe0b4 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("🌡 HS4001 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("💧 HS4001 humidity (%RH): "); + Serial.println(humidity); + } - // Check if the field is empty - if (fieldValue == "") { - continue; - } + int epaAqi = niclaSerial.airQualityIndex(); + if (epaAqi >= 0) { + Serial.print("🏭 ZMOD4510 EPA AQI: "); + Serial.println(epaAqi); + Serial.print("🏭 ZMOD4510 EPA AQI interpreted: "); + Serial.println(niclaSerial.airQualityIndexInterpreted()); + } - // 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.fastAirQualityIndex(); + if (fastAqi >= 0) { + Serial.print("🏭 ZMOD4510 Fast AQI: "); + Serial.println(fastAqi); } -} -void setup(){ - Serial.begin(115200); - Serial1.begin(38400, SERIAL_8N1); + float o3 = niclaSerial.O3(); + if (!isnan(o3)) { + Serial.print("🌬 ZMOD4510 O3 (ppb): "); + Serial.println(o3); + } - while (!Serial || !Serial1) { - delay(100); + float no2 = niclaSerial.NO2(); + if (!isnan(no2)) { + Serial.print("🌬 ZMOD4510 NO2 (ppb): "); + Serial.println(no2); } - Serial.println("Serial ports initialized"); -} + float iaq = niclaSerial.airQuality(); + if (!isnan(iaq)) { + Serial.print("🏠 ZMOD4410 IAQ: "); + Serial.println(iaq); + Serial.print("🏠 ZMOD4410 IAQ interpreted: "); + Serial.println(niclaSerial.airQualityInterpreted()); + } + float relIaq = niclaSerial.relativeAirQuality(); + if (!isnan(relIaq)) { + Serial.print("🏠 ZMOD4410 Rel IAQ: "); + Serial.println(relIaq); + } -void loop() { - if (!Serial1.available()) { - delay(100); - return; + float co2 = niclaSerial.CO2(); + if (!isnan(co2)) { + Serial.print("🌬 ZMOD4410 eCO2 (ppm): "); + Serial.println(co2); } - String csvLine = Serial1.readStringUntil('\n'); - processCSVLine(csvLine, DEFAULT_DELIMITER, parsedValuesMap); + float tvoc = niclaSerial.TVOC(); + if (!isnan(tvoc)) { + Serial.print("🌬 ZMOD4410 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("🍺 ZMOD4410 EtOH (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("👃 ZMOD4410 intensity: "); + Serial.println(odorIntensity); } - Serial.println(); + Serial.print("👃 ZMOD4410 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..d599302 --- /dev/null +++ b/src/NiclaSenseEnvSerial.cpp @@ -0,0 +1,244 @@ +#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; +} + +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::airQualityIndex() const { return _epaAqi; } +int NiclaSenseEnvSerial::fastAirQualityIndex() const { return _fastAqi; } +float NiclaSenseEnvSerial::NO2() const { return _no2; } +float NiclaSenseEnvSerial::O3() const { return _o3; } + +String NiclaSenseEnvSerial::airQualityIndexInterpreted() const { + int airQualityValue = airQualityIndex(); + 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::airQuality() const { return _iaq; } + +String NiclaSenseEnvSerial::airQualityInterpreted() const { + float iaqValue = airQuality(); + 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::relativeAirQuality() 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); + + if (fields[IDX_TEMPERATURE].length()) { + setFloatField(_temperature, fields[IDX_TEMPERATURE]); + } + if (fields[IDX_HUMIDITY].length()) { + setFloatField(_humidity, fields[IDX_HUMIDITY]); + } + if (fields[IDX_EPA_AQI].length()) { + setIntField(_epaAqi, fields[IDX_EPA_AQI]); + } + if (fields[IDX_FAST_AQI].length()) { + setIntField(_fastAqi, fields[IDX_FAST_AQI]); + } + if (fields[IDX_O3].length()) { + setFloatField(_o3, fields[IDX_O3]); + } + if (fields[IDX_NO2].length()) { + setFloatField(_no2, fields[IDX_NO2]); + } + if (fields[IDX_IAQ].length()) { + setFloatField(_iaq, fields[IDX_IAQ]); + } + if (fields[IDX_REL_IAQ].length()) { + setFloatField(_relativeIaq, fields[IDX_REL_IAQ]); + } + if (fields[IDX_CO2].length()) { + setFloatField(_co2, fields[IDX_CO2]); + } + if (fields[IDX_TVOC].length()) { + setFloatField(_tvoc, fields[IDX_TVOC]); + } + if (fields[IDX_ETHANOL].length()) { + setFloatField(_ethanol, fields[IDX_ETHANOL]); + } + if (fields[IDX_ODOR_INTENSITY].length()) { + setFloatField(_odorIntensity, fields[IDX_ODOR_INTENSITY]); + } + 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..1d1f6fd --- /dev/null +++ b/src/NiclaSenseEnvSerial.h @@ -0,0 +1,120 @@ +#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. */ + float temperature() const; + /** @brief Get the relative humidity value (Range 0-100%). */ + float humidity() const; + + // OutdoorAirQualitySensor compatible API + /** @brief Retrieves the EPA air quality index. Range is 0 to 500. */ + int airQualityIndex() const; + /** @brief Get the fast air quality index (1-minute averaging). */ + int fastAirQualityIndex() const; + /** @brief Get the NO2 value from the outdoor air quality sensor (ppb). */ + float NO2() const; + /** @brief Get the O3 value from the outdoor air quality sensor (ppb). */ + float O3() const; + /** @brief Interprets the EPA AQI into a textual description. */ + String airQualityIndexInterpreted() const; + + // IndoorAirQualitySensor compatible API + /** @brief Get the air quality value. Common range 0 to ~5. */ + float airQuality() const; + /** @brief Get the interpreted air quality value (Very Good, Good, Medium, Poor, Bad). */ + String airQualityInterpreted() const; + /** @brief Get the relative air quality value in percent (0 - 100%). */ + float relativeAirQuality() const; + /** @brief Get the CO2 value in ppm. */ + float CO2() const; + /** @brief Get the TVOC value in mg/m^3. */ + float TVOC() const; + /** @brief Get the ethanol value in ppm. */ + float ethanol() const; + /** @brief Get the odor intensity value. */ + float odorIntensity() const; + /** @brief Get the sulfur odor-detected value (true or false). */ + bool sulfurOdor() const; + + /** + * @brief Returns the last error message received over UART, empty when none. + */ + 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); + + 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 From 52c2737731652a0a25b588621e28fc0ab7a6843f Mon Sep 17 00:00:00 2001 From: Sebastian Romero Date: Thu, 18 Dec 2025 15:53:46 +0100 Subject: [PATCH 2/4] Improve API clarity --- examples/UARTRead/UARTRead.ino | 42 ++++++++-------- src/NiclaSenseEnvSerial.cpp | 16 +++--- src/NiclaSenseEnvSerial.h | 89 ++++++++++++++++++++++++++-------- 3 files changed, 97 insertions(+), 50 deletions(-) diff --git a/examples/UARTRead/UARTRead.ino b/examples/UARTRead/UARTRead.ino index 74fe0b4..cd84201 100644 --- a/examples/UARTRead/UARTRead.ino +++ b/examples/UARTRead/UARTRead.ino @@ -56,81 +56,81 @@ void loop() { float temperature = niclaSerial.temperature(); if (!isnan(temperature)) { - Serial.print("🌡 HS4001 temperature (°C): "); + Serial.print("🌡 Temperature (°C): "); Serial.println(temperature); } float humidity = niclaSerial.humidity(); if (!isnan(humidity)) { - Serial.print("💧 HS4001 humidity (%RH): "); + Serial.print("💧 Humidity (%RH): "); Serial.println(humidity); } - int epaAqi = niclaSerial.airQualityIndex(); + int epaAqi = niclaSerial.outdoorAirQualityIndex(); if (epaAqi >= 0) { - Serial.print("🏭 ZMOD4510 EPA AQI: "); + Serial.print("🏭 Outdoor EPA AQI: "); Serial.println(epaAqi); - Serial.print("🏭 ZMOD4510 EPA AQI interpreted: "); - Serial.println(niclaSerial.airQualityIndexInterpreted()); + Serial.print("🏭 Outdoor EPA AQI interpreted: "); + Serial.println(niclaSerial.outdoorAirQualityIndexInterpreted()); } - int fastAqi = niclaSerial.fastAirQualityIndex(); + int fastAqi = niclaSerial.outdoorFastAirQualityIndex(); if (fastAqi >= 0) { - Serial.print("🏭 ZMOD4510 Fast AQI: "); + Serial.print("🏭 Outdoor Fast AQI: "); Serial.println(fastAqi); } float o3 = niclaSerial.O3(); if (!isnan(o3)) { - Serial.print("🌬 ZMOD4510 O3 (ppb): "); + Serial.print("🌬 Outdoor O3 (ppb): "); Serial.println(o3); } float no2 = niclaSerial.NO2(); if (!isnan(no2)) { - Serial.print("🌬 ZMOD4510 NO2 (ppb): "); + Serial.print("🌬 Outdoor NO2 (ppb): "); Serial.println(no2); } - float iaq = niclaSerial.airQuality(); + float iaq = niclaSerial.indoorAirQuality(); if (!isnan(iaq)) { - Serial.print("🏠 ZMOD4410 IAQ: "); + Serial.print("🏠 Indoor IAQ: "); Serial.println(iaq); - Serial.print("🏠 ZMOD4410 IAQ interpreted: "); - Serial.println(niclaSerial.airQualityInterpreted()); + Serial.print("🏠 Indoor IAQ interpreted: "); + Serial.println(niclaSerial.indoorAirQualityInterpreted()); } - float relIaq = niclaSerial.relativeAirQuality(); + float relIaq = niclaSerial.indoorRelativeAirQuality(); if (!isnan(relIaq)) { - Serial.print("🏠 ZMOD4410 Rel IAQ: "); + Serial.print("🏠 Indoor relative IAQ: "); Serial.println(relIaq); } float co2 = niclaSerial.CO2(); if (!isnan(co2)) { - Serial.print("🌬 ZMOD4410 eCO2 (ppm): "); + Serial.print("🌬 Indoor eCO2 (ppm): "); Serial.println(co2); } float tvoc = niclaSerial.TVOC(); if (!isnan(tvoc)) { - Serial.print("🌬 ZMOD4410 TVOC (mg/m^3): "); + Serial.print("🌬 Indoor TVOC (mg/m^3): "); Serial.println(tvoc); } float ethanol = niclaSerial.ethanol(); if (!isnan(ethanol)) { - Serial.print("🍺 ZMOD4410 EtOH (ppm): "); + Serial.print("🍺 Ethanol (ppm): "); Serial.println(ethanol); } float odorIntensity = niclaSerial.odorIntensity(); if (!isnan(odorIntensity)) { - Serial.print("👃 ZMOD4410 intensity: "); + Serial.print("👃 Odor intensity: "); Serial.println(odorIntensity); } - Serial.print("👃 ZMOD4410 odor: "); + Serial.print("👃 Sulfur odor: "); Serial.println(niclaSerial.sulfurOdor() ? "detected" : "not detected"); Serial.println(); diff --git a/src/NiclaSenseEnvSerial.cpp b/src/NiclaSenseEnvSerial.cpp index d599302..9a7b4c0 100644 --- a/src/NiclaSenseEnvSerial.cpp +++ b/src/NiclaSenseEnvSerial.cpp @@ -97,13 +97,13 @@ bool NiclaSenseEnvSerial::update() { float NiclaSenseEnvSerial::temperature() const { return _temperature; } float NiclaSenseEnvSerial::humidity() const { return _humidity; } -int NiclaSenseEnvSerial::airQualityIndex() const { return _epaAqi; } -int NiclaSenseEnvSerial::fastAirQualityIndex() const { return _fastAqi; } +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::airQualityIndexInterpreted() const { - int airQualityValue = airQualityIndex(); +String NiclaSenseEnvSerial::outdoorAirQualityIndexInterpreted() const { + int airQualityValue = outdoorAirQualityIndex(); if (airQualityValue < 0) { return String("unknown"); } @@ -122,10 +122,10 @@ String NiclaSenseEnvSerial::airQualityIndexInterpreted() const { } } -float NiclaSenseEnvSerial::airQuality() const { return _iaq; } +float NiclaSenseEnvSerial::indoorAirQuality() const { return _iaq; } -String NiclaSenseEnvSerial::airQualityInterpreted() const { - float iaqValue = airQuality(); +String NiclaSenseEnvSerial::indoorAirQualityInterpreted() const { + float iaqValue = indoorAirQuality(); if (isnan(iaqValue)) { return String("unknown"); } @@ -142,7 +142,7 @@ String NiclaSenseEnvSerial::airQualityInterpreted() const { } } -float NiclaSenseEnvSerial::relativeAirQuality() const { return _relativeIaq; } +float NiclaSenseEnvSerial::indoorRelativeAirQuality() const { return _relativeIaq; } float NiclaSenseEnvSerial::CO2() const { return _co2; } float NiclaSenseEnvSerial::TVOC() const { return _tvoc; } float NiclaSenseEnvSerial::ethanol() const { return _ethanol; } diff --git a/src/NiclaSenseEnvSerial.h b/src/NiclaSenseEnvSerial.h index 1d1f6fd..13f4716 100644 --- a/src/NiclaSenseEnvSerial.h +++ b/src/NiclaSenseEnvSerial.h @@ -30,43 +30,90 @@ class NiclaSenseEnvSerial { bool update(); // TemperatureHumiditySensor compatible API - /** @brief Get the temperature value from the sensor in degrees Celsius. */ + /** + * @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 (Range 0-100%). */ + /** + * @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 EPA air quality index. Range is 0 to 500. */ - int airQualityIndex() const; - /** @brief Get the fast air quality index (1-minute averaging). */ - int fastAirQualityIndex() const; - /** @brief Get the NO2 value from the outdoor air quality sensor (ppb). */ + /** + * @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 (ppb). */ + /** + * @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 EPA AQI into a textual description. */ - String airQualityIndexInterpreted() 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 air quality value. Common range 0 to ~5. */ - float airQuality() const; - /** @brief Get the interpreted air quality value (Very Good, Good, Medium, Poor, Bad). */ - String airQualityInterpreted() const; - /** @brief Get the relative air quality value in percent (0 - 100%). */ - float relativeAirQuality() const; - /** @brief Get the CO2 value in ppm. */ + /** + * @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 in mg/m^3. */ + /** + * @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 in ppm. */ + /** + * @brief Get the ethanol value. + * @return Ethanol concentration in ppm, or NAN when unavailable. + */ float ethanol() const; - /** @brief Get the odor intensity value. */ + /** + * @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 value (true or false). */ + /** + * @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; From bd1b8c164a06776741a609fdb840d198f4903af1 Mon Sep 17 00:00:00 2001 From: Sebastian Romero Date: Thu, 18 Dec 2025 16:12:45 +0100 Subject: [PATCH 3/4] Avoid duplicated code --- src/NiclaSenseEnvSerial.cpp | 68 ++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/src/NiclaSenseEnvSerial.cpp b/src/NiclaSenseEnvSerial.cpp index 9a7b4c0..98eeeb8 100644 --- a/src/NiclaSenseEnvSerial.cpp +++ b/src/NiclaSenseEnvSerial.cpp @@ -68,6 +68,12 @@ 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; + +template +struct FieldMapping { + size_t idx; + T NiclaSenseEnvSerial::*member; +}; } NiclaSenseEnvSerial::NiclaSenseEnvSerial(HardwareSerial &serialPort) : _serial(&serialPort) {} @@ -167,42 +173,36 @@ void NiclaSenseEnvSerial::processCSVLine(String data) { auto fields = splitFields(data); - if (fields[IDX_TEMPERATURE].length()) { - setFloatField(_temperature, fields[IDX_TEMPERATURE]); - } - if (fields[IDX_HUMIDITY].length()) { - setFloatField(_humidity, fields[IDX_HUMIDITY]); - } - if (fields[IDX_EPA_AQI].length()) { - setIntField(_epaAqi, fields[IDX_EPA_AQI]); - } - if (fields[IDX_FAST_AQI].length()) { - setIntField(_fastAqi, fields[IDX_FAST_AQI]); - } - if (fields[IDX_O3].length()) { - setFloatField(_o3, fields[IDX_O3]); - } - if (fields[IDX_NO2].length()) { - setFloatField(_no2, fields[IDX_NO2]); - } - if (fields[IDX_IAQ].length()) { - setFloatField(_iaq, fields[IDX_IAQ]); - } - if (fields[IDX_REL_IAQ].length()) { - setFloatField(_relativeIaq, fields[IDX_REL_IAQ]); - } - if (fields[IDX_CO2].length()) { - setFloatField(_co2, fields[IDX_CO2]); - } - if (fields[IDX_TVOC].length()) { - setFloatField(_tvoc, fields[IDX_TVOC]); - } - if (fields[IDX_ETHANOL].length()) { - setFloatField(_ethanol, fields[IDX_ETHANOL]); + static const FieldMapping floatFields[] = { + {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}, + }; + + for (const auto &mapping : floatFields) { + if (fields[mapping.idx].length()) { + setFloatField(this->*mapping.member, fields[mapping.idx]); + } } - if (fields[IDX_ODOR_INTENSITY].length()) { - setFloatField(_odorIntensity, fields[IDX_ODOR_INTENSITY]); + + static const FieldMapping intFields[] = { + {IDX_EPA_AQI, &NiclaSenseEnvSerial::_epaAqi}, + {IDX_FAST_AQI, &NiclaSenseEnvSerial::_fastAqi}, + }; + + for (const auto &mapping : intFields) { + 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; From f274bb879c955e67e7443dcdae71eb6b0b8dba0a Mon Sep 17 00:00:00 2001 From: Sebastian Romero Date: Thu, 18 Dec 2025 16:23:26 +0100 Subject: [PATCH 4/4] Move lookup tables out of function --- src/NiclaSenseEnvSerial.cpp | 45 +++++++++++++++++-------------------- src/NiclaSenseEnvSerial.h | 9 ++++++++ 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/src/NiclaSenseEnvSerial.cpp b/src/NiclaSenseEnvSerial.cpp index 98eeeb8..a60a0e1 100644 --- a/src/NiclaSenseEnvSerial.cpp +++ b/src/NiclaSenseEnvSerial.cpp @@ -68,13 +68,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; +} -template -struct FieldMapping { - size_t idx; - T NiclaSenseEnvSerial::*member; +// 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) {} @@ -173,31 +186,13 @@ void NiclaSenseEnvSerial::processCSVLine(String data) { auto fields = splitFields(data); - static const FieldMapping floatFields[] = { - {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}, - }; - - for (const auto &mapping : floatFields) { + for (const auto &mapping : floatFieldMappings) { if (fields[mapping.idx].length()) { setFloatField(this->*mapping.member, fields[mapping.idx]); } } - static const FieldMapping intFields[] = { - {IDX_EPA_AQI, &NiclaSenseEnvSerial::_epaAqi}, - {IDX_FAST_AQI, &NiclaSenseEnvSerial::_fastAqi}, - }; - - for (const auto &mapping : intFields) { + for (const auto &mapping : intFieldMappings) { if (fields[mapping.idx].length()) { setIntField(this->*mapping.member, fields[mapping.idx]); } diff --git a/src/NiclaSenseEnvSerial.h b/src/NiclaSenseEnvSerial.h index 13f4716..dee8489 100644 --- a/src/NiclaSenseEnvSerial.h +++ b/src/NiclaSenseEnvSerial.h @@ -144,6 +144,15 @@ class NiclaSenseEnvSerial { */ 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;