From 94400124134bafe440e701de4817f3eb9dfc5592 Mon Sep 17 00:00:00 2001 From: Nicolas Liaudat Date: Fri, 16 Oct 2026 21:14:06 +0200 Subject: [PATCH] [mics_5524] Add MiCS-5524 gas sensor platform (1/2) (#19721) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/mics_5524/__init__.py | 19 ++ esphome/components/mics_5524/coefficients.py | 32 +++ esphome/components/mics_5524/mics_5524.cpp | 206 ++++++++++++++++++ esphome/components/mics_5524/mics_5524.h | 104 +++++++++ esphome/components/mics_5524/mics_math.h | 59 +++++ esphome/components/mics_5524/sensor.py | 153 +++++++++++++ esphome/core/defines.h | 1 + tests/component_tests/mics_5524/__init__.py | 0 .../mics_5524/config/mics_5524.yaml | 37 ++++ .../mics_5524/test_mics_5524.py | 205 +++++++++++++++++ tests/components/mics_5524/common.h | 23 ++ tests/components/mics_5524/common.yaml | 22 ++ .../mics_5524/sensor/mics_math_test.cpp | 93 ++++++++ .../components/mics_5524/test.esp32-idf.yaml | 5 + .../mics_5524/test.esp8266-ard.yaml | 5 + .../components/mics_5524/test.rp2040-ard.yaml | 5 + .../mics_5524/validate.esp32-idf.yaml | 23 ++ 18 files changed, 993 insertions(+) create mode 100644 esphome/components/mics_5524/__init__.py create mode 100644 esphome/components/mics_5524/coefficients.py create mode 100644 esphome/components/mics_5524/mics_5524.cpp create mode 100644 esphome/components/mics_5524/mics_5524.h create mode 100644 esphome/components/mics_5524/mics_math.h create mode 100644 esphome/components/mics_5524/sensor.py create mode 100644 tests/component_tests/mics_5524/__init__.py create mode 100644 tests/component_tests/mics_5524/config/mics_5524.yaml create mode 100644 tests/component_tests/mics_5524/test_mics_5524.py create mode 100644 tests/components/mics_5524/common.h create mode 100644 tests/components/mics_5524/common.yaml create mode 100644 tests/components/mics_5524/sensor/mics_math_test.cpp create mode 100644 tests/components/mics_5524/test.esp32-idf.yaml create mode 100644 tests/components/mics_5524/test.esp8266-ard.yaml create mode 100644 tests/components/mics_5524/test.rp2040-ard.yaml create mode 100644 tests/components/mics_5524/validate.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 38d2da4d870..55f58dbe696 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -391,6 +391,7 @@ esphome/components/micro_wake_word/* @jesserockz @kahrendt esphome/components/micronova/* @edenhaus @jorre05 esphome/components/microphone/* @jesserockz @kahrendt esphome/components/mics_4514/* @jesserockz +esphome/components/mics_5524/* @nliaudat esphome/components/midea/* @dudanov esphome/components/midea_ir/* @dudanov esphome/components/midea_portasplit/* @Fexiven diff --git a/esphome/components/mics_5524/__init__.py b/esphome/components/mics_5524/__init__.py new file mode 100644 index 00000000000..1b1c4111470 --- /dev/null +++ b/esphome/components/mics_5524/__init__.py @@ -0,0 +1,19 @@ +"""MiCS-5524 gas sensor platform: the DFRobot_MICS vendor model on a voltage sampler.""" + +import esphome.codegen as cg +from esphome.components import sensor + +CODEOWNERS = ["@nliaudat"] +DOMAIN = "mics_5524" + +mics_5524_ns = cg.esphome_ns.namespace("mics_5524") + +MiCS5524Component = mics_5524_ns.class_("MiCS5524Component", cg.PollingComponent) +MiCS5524GasSensor = mics_5524_ns.class_("MiCS5524GasSensor", sensor.Sensor) + +CONF_VOLTAGE_MULTIPLIER = "voltage_multiplier" +CONF_VCC = "vcc" +CONF_AIR_REFERENCE = "air_reference" +CONF_SAMPLE_COUNT = "sample_count" +CONF_SAMPLE_INTERVAL = "sample_interval" +CONF_MAX_PPM = "max_ppm" diff --git a/esphome/components/mics_5524/coefficients.py b/esphome/components/mics_5524/coefficients.py new file mode 100644 index 00000000000..cb1b0551f75 --- /dev/null +++ b/esphome/components/mics_5524/coefficients.py @@ -0,0 +1,32 @@ +"""Gas table of the DFRobot_MICS vendor model; the formula lives in mics_math.h.""" + +from dataclasses import dataclass + +from esphome.const import ( + CONF_AMMONIA, + CONF_CARBON_MONOXIDE, + CONF_ETHANOL, + CONF_HYDROGEN, + CONF_METHANE, +) + + +@dataclass(frozen=True) +class VendorCurve: + """Constants of the vendor model for one gas.""" + + label: str # vendor name of the gas + threshold: float # ratio at or below which the gas counts as detected + gain: float # ppm per unit ratio + min_ppm: float # the vendor reports 0 ppm below this + max_ppm: float # upper end of the measuring range + + +# DFRobot_MICS getGasData() thresholds and gains with the vendor measuring ranges. +GASES: dict[str, VendorCurve] = { + CONF_CARBON_MONOXIDE: VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0), + CONF_HYDROGEN: VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0), + CONF_ETHANOL: VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0), + CONF_AMMONIA: VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0), + CONF_METHANE: VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0), +} diff --git a/esphome/components/mics_5524/mics_5524.cpp b/esphome/components/mics_5524/mics_5524.cpp new file mode 100644 index 00000000000..2023661b369 --- /dev/null +++ b/esphome/components/mics_5524/mics_5524.cpp @@ -0,0 +1,206 @@ +#include "mics_5524.h" + +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::mics_5524 { + +ESPHOME_LOG_TAG(TAG, "mics_5524"); + +void MiCS5524GasSensor::publish_ratio(float ratio) { + const float ppm = micsmath::ppm_from_vendor_curve(ratio, this->curve_); + ESP_LOGD(TAG, "'%s': ratio=%.4f -> %.1f ppm", this->get_name().c_str(), ratio, ppm); + this->publish_state(ppm); +} + +void MiCS5524Component::setup() { + if (this->has_reference_()) { + ESP_LOGI(TAG, "using the air reference from the configuration (%.4f V)", this->reference_); + } + + if (this->warmup_time_ > 0) { + this->stop_poller(); + this->set_timeout(this->warmup_time_, [this]() { this->start_poller(); }); + ESP_LOGI(TAG, "warm-up of %" PRIu32 " s - no readings published before that", this->warmup_time_ / 1000); + } + + if (this->calibration_samples_ > 0) { + // Nothing is stored across reboots; the warm-up window starts here, so the delay covers it. + const uint32_t delay = std::max(this->calibration_delay_, this->warmup_time_); + this->set_timeout(CALIBRATION_START_ID, delay, [this]() { this->begin_calibration_(); }); + ESP_LOGI(TAG, "air reference calibration in %" PRIu32 " s - the sensor must be in clean air", delay / 1000); + } else if (!this->has_reference_()) { + ESP_LOGW(TAG, "no air reference available, the PPM values stay unknown\n" + " set 'air_reference:' or enable 'calibration:'"); + } + + // The loop only samples while a clean-air calibration runs; `update()` publishes. + this->disable_loop(); +} + +void MiCS5524Component::dump_config() { + ESP_LOGCONFIG(TAG, + "MiCS-5524:\n" + " VCC: %.2f V, AO multiplier: x%.3f, AO samples per reading: %u\n" + " Warm-up: %" PRIu32 " s, calibration: %s, sample spacing: %" PRIu32 " ms", + this->vcc_, this->voltage_multiplier_, static_cast(this->samples_), this->warmup_time_ / 1000, + this->calibration_samples_ > 0 ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"), + this->sample_interval_); + LOG_UPDATE_INTERVAL(this); + if (this->has_reference_()) { + ESP_LOGCONFIG(TAG, " Air reference (vcc - V_AO): %.4f V", this->reference_); + } else { + ESP_LOGCONFIG(TAG, " Air reference: not available yet"); + } +#ifdef MICS_5524_GAS_COUNT + for (auto *gas : this->gases_) { + LOG_SENSOR(" ", "Gas", gas); + ESP_LOGCONFIG(TAG, " Range: %.1f - %.1f ppm", gas->get_min_ppm(), gas->get_max_ppm()); + } +#endif +} + +float MiCS5524Component::sample_voltage_() { + // Taken back to back; waiting between them would block the main loop. + float sum = 0.0f; + for (uint8_t i = 0; i < this->samples_; i++) + sum += this->source_->sample(); + return (sum / static_cast(this->samples_)) * this->voltage_multiplier_; +} + +void MiCS5524Component::publish_ratio_(float ratio) { +#ifdef MICS_5524_GAS_COUNT + for (auto *gas : this->gases_) { + gas->publish_ratio(ratio); + } +#endif +} + +float MiCS5524Component::read_air_value_() { + const float voltage = this->sample_voltage_(); + const float x = micsmath::air_value_from_voltage(voltage, this->vcc_); + if (std::isfinite(x)) + return x; + // The status flag makes this log once per fault, not once per update. + if (!this->status_has_warning()) { + ESP_LOGW(TAG, "analog output reads %.4f V with vcc %.2f V\n check the AO wiring, 'vcc:' and 'voltage_multiplier:'", + voltage, this->vcc_); + } + this->status_set_warning(LOG_STR("analog output out of range")); + return NAN; +} + +void MiCS5524Component::update() { + // Every gas starts unknown and the reference is never cleared, so there is nothing to republish. + if (!this->has_reference_()) + return; + + const float air_value = this->read_air_value_(); + if (std::isnan(air_value)) { + this->publish_ratio_(NAN); + return; + } + this->status_clear_warning(); + + const float ratio = micsmath::ratio_from_air_value(air_value, this->reference_); + ESP_LOGD(TAG, "V_AO=%.4f V, x=%.4f, ratio=%.4f", this->vcc_ - air_value, air_value, ratio); + this->publish_ratio_(ratio); +} + +void MiCS5524Component::set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval) { + this->calibration_delay_ = delay; + this->calibration_samples_ = samples; + this->sample_interval_ = sample_interval; +} + +void MiCS5524Component::loop() { + if (!this->calibrating_) { + // Only a calibration samples here; the deferred start is a timer. + this->disable_loop(); + return; + } + + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->calibration_last_sample_ < this->sample_interval_) + return; + + this->calibration_last_sample_ = now; + this->process_calibration_(); +} + +void MiCS5524Component::begin_calibration_() { + this->calibrating_ = true; + this->calibration_last_sample_ = App.get_loop_component_start_time(); + this->calibration_count_ = 0; + this->calibration_attempts_ = 0; + this->calibration_sum_ = 0.0f; + this->calibration_min_ = INFINITY; + this->calibration_max_ = -INFINITY; + this->enable_loop(); + + ESP_LOGI(TAG, "calibrating the air reference, %" PRIu32 " samples - keep the sensor in clean air", + this->calibration_samples_); +} + +void MiCS5524Component::process_calibration_() { + this->calibration_attempts_++; + + const float reference = this->read_air_value_(); + if (!std::isnan(reference)) { + this->calibration_min_ = std::min(this->calibration_min_, reference); + this->calibration_max_ = std::max(this->calibration_max_, reference); + this->calibration_sum_ += reference; + this->calibration_count_++; + } + + if (this->calibration_attempts_ >= this->calibration_samples_) + this->finish_calibration_(); +} + +void MiCS5524Component::finish_calibration_() { + this->calibrating_ = false; + this->disable_loop(); + + const uint32_t attempts = this->calibration_attempts_; + const uint32_t valid = this->calibration_count_; + // A majority of the attempts has to have produced a sample, two at least. + const uint32_t required = std::max((attempts + 1) / 2, 2); + + if (valid < required) { + ESP_LOGW(TAG, "calibration failed, %" PRIu32 " of %" PRIu32 " samples were valid", valid, attempts); + this->schedule_calibration_retry_(); + return; + } + + const float reference = this->calibration_sum_ / static_cast(valid); + + // A sensor still settling, or not in clean air, varies too much to be the reference. + if (!micsmath::calibration_is_stable(this->calibration_min_, this->calibration_max_, reference)) { + ESP_LOGW( + TAG, "clean-air samples spread %.4f V around %.4f V (%.4f - %.4f V)\n the sensor was not settled in clean air", + this->calibration_max_ - this->calibration_min_, reference, this->calibration_min_, this->calibration_max_); + this->schedule_calibration_retry_(); + return; + } + + this->reference_ = reference; + this->status_clear_warning(); + + ESP_LOGI(TAG, + "air reference = %.4f (%" PRIu32 "/%" PRIu32 " samples valid)\n" + " hard-code 'air_reference: %.4f' to skip the calibration at boot", + this->reference_, valid, attempts, this->reference_); +} + +void MiCS5524Component::schedule_calibration_retry_() { + this->status_set_warning(); + // A zero delay would log the same failure about five times per second. + const uint32_t delay = std::max(this->calibration_delay_, CALIBRATION_RETRY_MIN_MS); + this->set_timeout(CALIBRATION_START_ID, delay, [this]() { this->begin_calibration_(); }); + ESP_LOGW(TAG, "retrying the calibration in %" PRIu32 " s", delay / 1000); +} + +} // namespace esphome::mics_5524 diff --git a/esphome/components/mics_5524/mics_5524.h b/esphome/components/mics_5524/mics_5524.h new file mode 100644 index 00000000000..de7263e5d78 --- /dev/null +++ b/esphome/components/mics_5524/mics_5524.h @@ -0,0 +1,104 @@ +#pragma once + +#include +#include + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/voltage_sampler/voltage_sampler.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include "mics_math.h" + +namespace esphome::mics_5524 { + +/// One gas of a MiCS-5524: converts the measured ratio with the vendor curve of that gas. +class MiCS5524GasSensor : public sensor::Sensor { + public: + /// The vendor constants from coefficients.py. + MiCS5524GasSensor(float threshold, float gain, float min_ppm, float max_ppm) + : curve_{threshold, gain, min_ppm, max_ppm} {} + + /// Publish the concentration for `ratio`; NAN publishes `unknown`. + void publish_ratio(float ratio); + + float get_min_ppm() const { return this->curve_.min_ppm; } + float get_max_ppm() const { return this->curve_.max_ppm; } + + protected: + micsmath::VendorCurve curve_; +}; + +/// The MiCS-5524 breakout: one analog output, converted for every configured gas against +/// the clean-air reference x_air from `air_reference:` or a calibration at boot. +class MiCS5524Component : public PollingComponent { + public: + /// `source` must outlive this component, which code generation guarantees. + explicit MiCS5524Component(voltage_sampler::VoltageSampler *source) : source_(source) {} + + void setup() override; + void loop() override; + void update() override; + void dump_config() override; + +#ifdef MICS_5524_GAS_COUNT + void register_gas_sensor(MiCS5524GasSensor *gas) { this->gases_.push_back(gas); } +#endif + + void set_vcc(float vcc) { this->vcc_ = vcc; } + void set_voltage_multiplier(float multiplier) { this->voltage_multiplier_ = multiplier; } + void set_samples(uint8_t samples) { this->samples_ = samples; } + void set_warmup_time(uint32_t warmup) { this->warmup_time_ = warmup; } + /// Calibrate at boot: start after `delay` ms, average `samples` readings `sample_interval` ms apart. + void set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval); + void set_air_reference(float air_reference) { this->reference_ = air_reference; } + + protected: + /// Averaged, scaled output voltage (V); the conversions run back to back. + float sample_voltage_(); + /// x = vcc - V_AO, or NAN (and a status warning) when the voltage is unusable. + float read_air_value_(); + void publish_ratio_(float ratio); + + void begin_calibration_(); + void process_calibration_(); + void finish_calibration_(); + void schedule_calibration_retry_(); + bool has_reference_() const { return this->reference_ > 0.0f; } + + voltage_sampler::VoltageSampler *source_{nullptr}; + float vcc_{5.0f}; + float voltage_multiplier_{1.0f}; + uint32_t warmup_time_{0}; + + uint32_t calibration_delay_{0}; + /// 0 when calibration is disabled. + uint32_t calibration_samples_{0}; + /// Spacing of the clean-air calibration samples (ms); the ~16 ms loop tick is the floor. + uint32_t sample_interval_{20}; + + static constexpr uint32_t CALIBRATION_START_ID = 0; + /// Floor of the delay between two calibration attempts (ms). + static constexpr uint32_t CALIBRATION_RETRY_MIN_MS = 10000; + + uint32_t calibration_last_sample_{0}; + uint32_t calibration_count_{0}; + uint32_t calibration_attempts_{0}; + float calibration_sum_{0.0f}; + /// Smallest and largest clean-air sample of the run, see calibration_is_stable(). + float calibration_min_{0.0f}; + float calibration_max_{0.0f}; + + /// Clean-air reference x_air in volts, 0 while there is none. + float reference_{0.0f}; + + // Sized by code generation; the host unit tests run none, so the list compiles out there. +#ifdef MICS_5524_GAS_COUNT + StaticVector gases_; +#endif + + uint8_t samples_{4}; + bool calibrating_{false}; +}; + +} // namespace esphome::mics_5524 diff --git a/esphome/components/mics_5524/mics_math.h b/esphome/components/mics_5524/mics_math.h new file mode 100644 index 00000000000..e66def345db --- /dev/null +++ b/esphome/components/mics_5524/mics_math.h @@ -0,0 +1,59 @@ +#pragma once +// Pure math of the MiCS-5524 vendor model; the per-gas constants live in coefficients.py. +// x = VCC - V_ao, ratio = x / x_air (sampled in clean air), ppm = (threshold - ratio) / gain. +// No ESPHome dependencies, so the host tests cover it. + +#include + +namespace esphome::mics_5524::micsmath { + +/// Per-gas constants of the vendor model. +struct VendorCurve { + float threshold; ///< ratio at or below which the gas counts as detected + float gain; ///< ppm per unit ratio + float min_ppm; ///< the vendor reports 0 ppm below this + float max_ppm; ///< upper end of the measuring range +}; + +/// x = VCC - V_ao: 0 at VCC (a saturated sensor, never clean air). NaN at or below 0 V (a +/// disconnected pin) and above VCC, which only a wrong vcc or voltage_multiplier produces. +inline float air_value_from_voltage(float voltage, float vcc) { + if (!std::isfinite(voltage) || !std::isfinite(vcc) || voltage <= 0.0f || voltage > vcc) + return NAN; + return vcc - voltage; +} + +/// Largest relative spread (max - min) / mean a clean-air calibration accepts. +inline constexpr float MAX_CALIBRATION_SPREAD = 0.25f; + +/// True when the clean-air samples spread at most MAX_CALIBRATION_SPREAD of their mean. +inline bool calibration_is_stable(float min_value, float max_value, float mean) { + if (!std::isfinite(min_value) || !std::isfinite(max_value) || !std::isfinite(mean) || mean <= 0.0f) + return false; + return max_value - min_value <= MAX_CALIBRATION_SPREAD * mean; +} + +/// ratio = x / x_air (NaN when either value is unusable). +inline float ratio_from_air_value(float x, float x_air) { + if (!std::isfinite(x) || x < 0.0f || !std::isfinite(x_air) || x_air <= 0.0f) + return NAN; + return x / x_air; +} + +/// ratio -> ppm as DFRobot_MICS getGasData(): 0 above the threshold or below the vendor +/// range, the top of the range at ratio 0 (a saturated sensor), NaN stays NaN. +inline float ppm_from_vendor_curve(float ratio, const VendorCurve &curve) { + if (!std::isfinite(ratio)) + return NAN; + if (ratio > curve.threshold) + return 0.0f; + + const float ppm = (curve.threshold - ratio) / curve.gain; + if (ppm < curve.min_ppm) + return 0.0f; + if (ppm > curve.max_ppm) + return curve.max_ppm; + return ppm; +} + +} // namespace esphome::mics_5524::micsmath diff --git a/esphome/components/mics_5524/sensor.py b/esphome/components/mics_5524/sensor.py new file mode 100644 index 00000000000..f2b05c8da2e --- /dev/null +++ b/esphome/components/mics_5524/sensor.py @@ -0,0 +1,153 @@ +"""MiCS-5524 sensor platform: one entry per module, one sub-sensor per configured gas.""" + +import logging + +import esphome.codegen as cg +from esphome.components import sensor, voltage_sampler +import esphome.config_validation as cv +from esphome.const import ( + CONF_CALIBRATION, + CONF_DELAY, + CONF_ID, + CONF_VOLTAGE, + CONF_WARMUP_TIME, + STATE_CLASS_MEASUREMENT, + UNIT_PARTS_PER_MILLION, +) +from esphome.types import ConfigType + +from . import ( + CONF_AIR_REFERENCE, + CONF_MAX_PPM, + CONF_SAMPLE_COUNT, + CONF_SAMPLE_INTERVAL, + CONF_VCC, + CONF_VOLTAGE_MULTIPLIER, + MiCS5524Component, + MiCS5524GasSensor, +) +from .coefficients import GASES + +_LOGGER = logging.getLogger(__name__) + +AUTO_LOAD = ["sensor", "voltage_sampler"] + +# The main loop runs every ~16 ms, so a shorter spacing cannot sample more often. +MIN_SAMPLE_INTERVAL_MS = 16 + +# Conversions per reading run back to back inside one loop pass. +MAX_SAMPLE_COUNT = 16 + +_request_gas_slot = cg.slot_counter("MICS_5524_GAS_COUNT") + + +def _gas_schema(gas_key: str) -> cv.Schema: + """Schema of one gas sub-sensor.""" + curve = GASES[gas_key] + return sensor.sensor_schema( + MiCS5524GasSensor, + unit_of_measurement=UNIT_PARTS_PER_MILLION, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:molecule", + ).extend( + { + # A ceiling above the vendor range could never be reached. + cv.Optional(CONF_MAX_PPM, default=curve.max_ppm): cv.float_range( + min=0, min_included=False, max=curve.max_ppm + ) + } + ) + + +CALIBRATION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_DELAY, default="60s"): cv.positive_time_period_milliseconds, + cv.Optional(CONF_SAMPLE_COUNT, default=10): cv.int_range( + min=2, max=MAX_SAMPLE_COUNT + ), + cv.Optional(CONF_SAMPLE_INTERVAL, default="20ms"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=cv.TimePeriod(milliseconds=MIN_SAMPLE_INTERVAL_MS)), + ), + } +) + + +def _validate_config(config: ConfigType) -> ConfigType: + """Reject an air reference at or above the supply; warn when there is none.""" + if (air_reference := config.get(CONF_AIR_REFERENCE)) is not None: + # The reference is vcc - V_AO in clean air, so a larger one reads 0 ppm forever. + if air_reference >= config[CONF_VCC]: + raise cv.Invalid( + f"air_reference ({air_reference:g} V) must be below vcc " + f"({config[CONF_VCC]:g} V): it is how far the analog output sits " + "below the supply in clean air" + ) + elif CONF_CALIBRATION not in config: + _LOGGER.warning( + "no air reference configured - set 'air_reference:' or 'calibration:', " + "the PPM values stay unknown until then" + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MiCS5524Component), + cv.Required(CONF_VOLTAGE): cv.use_id(voltage_sampler.VoltageSampler), + cv.Optional( + CONF_VOLTAGE_MULTIPLIER, default=1.0 + ): cv.positive_not_null_float, + cv.Optional(CONF_VCC, default=5.0): cv.positive_not_null_float, + cv.Optional(CONF_AIR_REFERENCE): cv.positive_not_null_float, + cv.Optional(CONF_SAMPLE_COUNT, default=4): cv.int_range( + min=1, max=MAX_SAMPLE_COUNT + ), + cv.Optional( + CONF_WARMUP_TIME, default="3min" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_CALIBRATION): CALIBRATION_SCHEMA, + **{cv.Optional(gas_key): _gas_schema(gas_key) for gas_key in GASES}, + } + ).extend(cv.polling_component_schema("30s")), + cv.has_at_least_one_key(*GASES), + cv.has_at_most_one_key(CONF_AIR_REFERENCE, CONF_CALIBRATION), + _validate_config, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], await cg.get_variable(config[CONF_VOLTAGE])) + await cg.register_component(var, config) + + cg.add(var.set_vcc(config[CONF_VCC])) + cg.add(var.set_voltage_multiplier(config[CONF_VOLTAGE_MULTIPLIER])) + cg.add(var.set_samples(config[CONF_SAMPLE_COUNT])) + cg.add(var.set_warmup_time(config[CONF_WARMUP_TIME])) + + if (air_reference := config.get(CONF_AIR_REFERENCE)) is not None: + cg.add(var.set_air_reference(air_reference)) + elif (calibration := config.get(CONF_CALIBRATION)) is not None: + cg.add( + var.set_calibration( + calibration[CONF_DELAY], + calibration[CONF_SAMPLE_COUNT], + calibration[CONF_SAMPLE_INTERVAL], + ) + ) + + sensors = sensor.sub_sensors(config) + for gas_key, curve in GASES.items(): + if (gas_config := config.get(gas_key)) is None: + continue + _request_gas_slot(str(var)) + await sensors( + gas_key, + var.register_gas_sensor, + curve.threshold, + curve.gain, + curve.min_ppm, + gas_config[CONF_MAX_PPM], + ) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0655c4a399c..cd0f0d39103 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -165,6 +165,7 @@ #define MDNS_DYNAMIC_TXT_COUNT 2 #define LD2450_POLYGON_ZONE_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 +#define MICS_5524_GAS_COUNT 5 #define USE_MICRONOVA_WRITER #define MK2PVROUTER_LISTENER_COUNT 1 #define NFC_TAG_LISTENER_COUNT 1 diff --git a/tests/component_tests/mics_5524/__init__.py b/tests/component_tests/mics_5524/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/mics_5524/config/mics_5524.yaml b/tests/component_tests/mics_5524/config/mics_5524.yaml new file mode 100644 index 00000000000..7538fb973e1 --- /dev/null +++ b/tests/component_tests/mics_5524/config/mics_5524.yaml @@ -0,0 +1,37 @@ +esphome: + name: test + +esp32: + board: esp32dev + +sensor: + - platform: adc + id: mics_5524_adc + name: MiCS-5524 AO + pin: GPIO34 + + # Calibrated at boot; every gas below it is published from the same ratio. + - platform: mics_5524 + id: mics_5524_boot + voltage: mics_5524_adc + voltage_multiplier: 1.5 + sample_count: 3 + calibration: + delay: 5s + sample_count: 3 + carbon_monoxide: + id: mics_5524_co + name: MiCS-5524 CO + max_ppm: 500.0 + methane: + id: mics_5524_ch4 + name: MiCS-5524 CH4 + + # The reference is hard-coded, so nothing is calibrated at boot. + - platform: mics_5524 + id: mics_5524_fixed + voltage: mics_5524_adc + air_reference: 3.6 + hydrogen: + id: mics_5524_h2 + name: MiCS-5524 H2 diff --git a/tests/component_tests/mics_5524/test_mics_5524.py b/tests/component_tests/mics_5524/test_mics_5524.py new file mode 100644 index 00000000000..53dc2f49708 --- /dev/null +++ b/tests/component_tests/mics_5524/test_mics_5524.py @@ -0,0 +1,205 @@ +"""Tests for the MiCS-5524 gas sensor code generation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components.mics_5524.coefficients import GASES, VendorCurve +from esphome.components.mics_5524.sensor import CONFIG_SCHEMA +from esphome.const import ( + CONF_AMMONIA, + CONF_CARBON_MONOXIDE, + CONF_ETHANOL, + CONF_HYDROGEN, + CONF_METHANE, +) + + +def test_voltage_source_is_a_constructor_argument( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Every reading needs the sampler, so it is passed at construction.""" + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + + assert ( + "new(mics_5524_boot) mics_5524::MiCS5524Component(mics_5524_adc);" in main_cpp + ) + assert ( + "new(mics_5524_fixed) mics_5524::MiCS5524Component(mics_5524_adc);" in main_cpp + ) + assert "set_source(" not in main_cpp + + +def test_every_gas_becomes_a_sub_sensor_of_its_hub( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The curve is passed to the constructor and the sub-sensor to its own hub.""" + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + + # Threshold, gain, minimum and maximum ppm of the gas. + assert ( + "new(mics_5524_co) mics_5524::MiCS5524GasSensor(0.425f, " + "0.000405f, 1.0f, 500.0f);" in main_cpp + ) + assert "mics_5524_boot->register_gas_sensor(mics_5524_co);" in main_cpp + # A gas without `max_ppm:` keeps the measuring range of the vendor. + assert ( + "new(mics_5524_ch4) mics_5524::MiCS5524GasSensor(0.786f, " + "2.3e-05f, 1000.0f, 25000.0f);" in main_cpp + ) + assert "mics_5524_boot->register_gas_sensor(mics_5524_ch4);" in main_cpp + # The gas belongs to the sensor it is configured under, not to the first one. + assert "mics_5524_fixed->register_gas_sensor(mics_5524_h2);" in main_cpp + assert "mics_5524_boot->register_gas_sensor(mics_5524_h2);" not in main_cpp + + +def test_shared_options_reach_the_hub( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The analog front end is configured once per physical sensor.""" + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + + assert "mics_5524_boot->set_vcc(5.0f);" in main_cpp + assert "mics_5524_boot->set_voltage_multiplier(1.5f);" in main_cpp + assert "mics_5524_boot->set_samples(3);" in main_cpp + assert "mics_5524_boot->set_warmup_time(180000);" in main_cpp + assert "mics_5524_boot->set_calibration(5000, 3, 20);" in main_cpp + assert "mics_5524_fixed->set_air_reference(3.6f);" in main_cpp + # `air_reference:` and `calibration:` exclude each other, so the sensor with the + # hard-coded reference is never calibrated at boot. + assert "mics_5524_fixed->set_calibration(" not in main_cpp + + +def test_air_reference_and_calibration_exclude_each_other() -> None: + """Two references for one sensor would silently pick one, so it is an error.""" + with pytest.raises(cv.Invalid, match="Cannot specify more than one of"): + CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "hydrogen": {"name": "MiCS-5524 H2 (reference)"}, + "air_reference": 3.6, + "calibration": {"delay": "5s"}, + } + ) + + +@pytest.mark.parametrize("air_reference", [5.0, 6.0]) +def test_air_reference_at_or_above_vcc_is_rejected(air_reference: float) -> None: + """A reference at or above the supply would report every gas as 0 ppm forever.""" + with pytest.raises(cv.Invalid, match="must be below vcc"): + CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "vcc": 5.0, + "hydrogen": {"name": "MiCS-5524 H2 (reference above vcc)"}, + "air_reference": air_reference, + } + ) + + +def test_air_reference_below_vcc_is_accepted() -> None: + config = CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "vcc": 5.0, + "hydrogen": {"name": "MiCS-5524 H2 (reference)"}, + "air_reference": 3.6, + } + ) + + assert config["air_reference"] == 3.6 + + +def test_at_least_one_gas_is_required() -> None: + """A sensor without a gas has nothing to publish.""" + with pytest.raises(cv.Invalid, match="Must contain at least one of"): + CONFIG_SCHEMA({"id": "mics_5524", "voltage": "mics_5524_adc", "vcc": 5.0}) + + +def test_max_ppm_above_the_vendor_range_is_rejected() -> None: + """A ceiling the conversion can never reach is a configuration error.""" + with pytest.raises(cv.Invalid, match="value must be at most 1000.0"): + CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "carbon_monoxide": { + "name": "MiCS-5524 CO (rejected)", + "max_ppm": 2000.0, + }, + "air_reference": 3.6, + } + ) + + +def test_max_ppm_at_the_vendor_range_is_accepted() -> None: + config = CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "carbon_monoxide": {"name": "MiCS-5524 CO (accepted)", "max_ppm": 1000.0}, + "air_reference": 3.6, + } + ) + + assert config["carbon_monoxide"]["max_ppm"] == 1000.0 + + +def test_max_ppm_defaults_to_the_vendor_range_of_the_gas() -> None: + config = CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "carbon_monoxide": {"name": "MiCS-5524 CO (default)"}, + "air_reference": 3.6, + } + ) + + assert config["carbon_monoxide"]["max_ppm"] == 1000.0 + + +def test_calibration_cannot_sample_faster_than_the_loop() -> None: + """The main loop runs every ~16 ms, so a shorter spacing samples nothing extra.""" + with pytest.raises(cv.Invalid, match="value must be at least 16ms"): + CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "hydrogen": {"name": "MiCS-5524 H2 (tick)"}, + "calibration": {"sample_interval": "10ms"}, + } + ) + + +def test_voltage_multiplier_defaults_to_one() -> None: + config = CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "hydrogen": {"name": "MiCS-5524 H2 (default)"}, + "air_reference": 3.6, + } + ) + + assert config["voltage_multiplier"] == 1.0 + + +def test_vendor_table_is_the_datasheet_one() -> None: + """The whole table is pinned, not only the gases a config file happens to use.""" + expected = { + CONF_CARBON_MONOXIDE: VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0), + CONF_HYDROGEN: VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0), + CONF_ETHANOL: VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0), + CONF_AMMONIA: VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0), + CONF_METHANE: VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0), + } + + assert expected == GASES diff --git a/tests/components/mics_5524/common.h b/tests/components/mics_5524/common.h new file mode 100644 index 00000000000..7f46ba9bea3 --- /dev/null +++ b/tests/components/mics_5524/common.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include + +#include "esphome/components/mics_5524/mics_math.h" + +namespace esphome::mics_5524::testing { + +using micsmath::air_value_from_voltage; +using micsmath::calibration_is_stable; +using micsmath::ppm_from_vendor_curve; +using micsmath::ratio_from_air_value; + +// Independent copy of the DFRobot table; the Python tests pin coefficients.py itself. +constexpr micsmath::VendorCurve CO{0.425f, 0.000405f, 1.0f, 1000.0f}; +constexpr micsmath::VendorCurve H2{0.279f, 0.00026f, 1.0f, 1000.0f}; +constexpr micsmath::VendorCurve NH3{0.8f, 0.0015f, 10.0f, 500.0f}; +constexpr micsmath::VendorCurve C2H5OH{0.306f, 0.00057f, 10.0f, 500.0f}; +constexpr micsmath::VendorCurve CH4{0.786f, 0.000023f, 1000.0f, 25000.0f}; + +} // namespace esphome::mics_5524::testing diff --git a/tests/components/mics_5524/common.yaml b/tests/components/mics_5524/common.yaml new file mode 100644 index 00000000000..eb5f69b14ee --- /dev/null +++ b/tests/components/mics_5524/common.yaml @@ -0,0 +1,22 @@ +sensor: + - platform: adc + id: mics_5524_adc + name: MiCS-5524 AO + pin: ${mics_pin} + + - platform: mics_5524 + id: mics_5524_module + # 10k/20k divider: the 5 V breakout's 3.33 V stays under the ADC limit. + voltage: mics_5524_adc + voltage_multiplier: 1.5 + sample_count: 3 + warmup_time: 1s + calibration: + delay: 5s + sample_count: 3 + sample_interval: 20ms + carbon_monoxide: + name: MiCS-5524 CO + max_ppm: 500.0 + hydrogen: + name: MiCS-5524 H2 diff --git a/tests/components/mics_5524/sensor/mics_math_test.cpp b/tests/components/mics_5524/sensor/mics_math_test.cpp new file mode 100644 index 00000000000..946b4fa2e2e --- /dev/null +++ b/tests/components/mics_5524/sensor/mics_math_test.cpp @@ -0,0 +1,93 @@ +#include "../common.h" + +namespace esphome::mics_5524::testing { + +TEST(MicsMath, SaturatedSensorReportsTheTopOfTheRange) { + // ratio 0 is V_AO at VCC. The vendor helpers return the top of the range there, + // so a saturated sensor must not be converted into a clean-air reading. + EXPECT_FLOAT_EQ(CO.max_ppm, ppm_from_vendor_curve(0.0f, CO)); + EXPECT_FLOAT_EQ(H2.max_ppm, ppm_from_vendor_curve(0.0f, H2)); + EXPECT_FLOAT_EQ(NH3.max_ppm, ppm_from_vendor_curve(0.0f, NH3)); + EXPECT_FLOAT_EQ(C2H5OH.max_ppm, ppm_from_vendor_curve(0.0f, C2H5OH)); + EXPECT_FLOAT_EQ(CH4.max_ppm, ppm_from_vendor_curve(0.0f, CH4)); +} + +TEST(MicsMath, GasFreeAirReadsZero) { + // At or above the threshold the vendor reports no gas, and so does a value below + // its own measuring range. + EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(CO.threshold, CO)); + EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(CO.threshold + 0.01f, CO)); + EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(1.0f, CO)); + EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(0.4249f, CO)); // 0.25 ppm, below the range +} + +TEST(MicsMath, MidRangeReadingsFollowTheVendorFormula) { + // (0.425 - 0.2) / 0.000405 = 555.56 ppm + EXPECT_NEAR(555.556f, ppm_from_vendor_curve(0.2f, CO), 0.01f); + // (0.8 - 0.7) / 0.0015 = 66.67 ppm + EXPECT_NEAR(66.667f, ppm_from_vendor_curve(0.7f, NH3), 0.01f); + // (0.279 - 0.1) / 0.00026 = 688.46 ppm + EXPECT_NEAR(688.462f, ppm_from_vendor_curve(0.1f, H2), 0.01f); +} + +TEST(MicsMath, UnusableInputReadsUnknown) { + EXPECT_TRUE(std::isnan(ppm_from_vendor_curve(NAN, CO))); + EXPECT_TRUE(std::isnan(ppm_from_vendor_curve(INFINITY, CO))); + EXPECT_TRUE(std::isnan(air_value_from_voltage(NAN, 5.0f))); + EXPECT_TRUE(std::isnan(air_value_from_voltage(4.0f, NAN))); + EXPECT_TRUE(std::isnan(ratio_from_air_value(1.0f, NAN))); + EXPECT_TRUE(std::isnan(ratio_from_air_value(NAN, 1.0f))); + // A missing clean-air reference (x_air = 0) must not be read as a real ratio. + EXPECT_TRUE(std::isnan(ratio_from_air_value(1.0f, 0.0f))); +} + +TEST(MicsMath, AirValueIsTheVoltageBelowVcc) { + EXPECT_FLOAT_EQ(1.0f, air_value_from_voltage(4.0f, 5.0f)); + // At VCC the vendor value is 0, the saturated end of the range ... + EXPECT_FLOAT_EQ(0.0f, air_value_from_voltage(5.0f, 5.0f)); + // ... but a voltage above VCC cannot come from the sensor, so it is unusable rather + // than saturated: a wrong `vcc:` is not reported as a real reading. + EXPECT_TRUE(std::isnan(air_value_from_voltage(5.2f, 5.0f))); + // A pin at or below 0 V is disconnected, not a reading of VCC. + EXPECT_TRUE(std::isnan(air_value_from_voltage(0.0f, 5.0f))); + EXPECT_TRUE(std::isnan(air_value_from_voltage(-0.1f, 5.0f))); + // The two ends of the vendor range, and the unusable case, one by one. + EXPECT_FLOAT_EQ(1.0f, ratio_from_air_value(air_value_from_voltage(4.0f, 5.0f), 1.0f)); + EXPECT_FLOAT_EQ(0.0f, ratio_from_air_value(air_value_from_voltage(5.0f, 5.0f), 1.0f)); + EXPECT_TRUE(std::isnan(ratio_from_air_value(air_value_from_voltage(6.0f, 5.0f), 1.0f))); +} + +TEST(MicsMath, CalibrationSamplesMustAgree) { + // Clean air at 3.6 V with a few millivolts of ADC noise: a settled reference. + EXPECT_TRUE(calibration_is_stable(3.555f, 3.645f, 3.6f)); + // A sensor that is still settling or not in clean air: (4.9 - 0.1) / 2.5 = 1.92. + EXPECT_FALSE(calibration_is_stable(0.1f, 4.9f, 2.5f)); + // The limit is inclusive: 0.25 of the mean passes, a little more does not. + EXPECT_TRUE(calibration_is_stable(3.0f, 4.0f, 4.0f)); + EXPECT_FALSE(calibration_is_stable(2.9f, 4.0f, 4.0f)); + // Unusable values are never a settled reference. + EXPECT_FALSE(calibration_is_stable(NAN, 3.6f, 3.6f)); + EXPECT_FALSE(calibration_is_stable(3.6f, NAN, 3.6f)); + EXPECT_FALSE(calibration_is_stable(3.6f, 3.6f, NAN)); + EXPECT_FALSE(calibration_is_stable(3.6f, 3.6f, 0.0f)); +} + +TEST(MicsMath, ConfiguredCeilingCapsTheReading) { + // `max_ppm:` narrows the vendor range: a reading above the ceiling is reported as + // the ceiling, never as a value the sensor is not configured for. + const micsmath::VendorCurve narrow{0.425f, 0.000405f, 1.0f, 500.0f}; + EXPECT_FLOAT_EQ(500.0f, ppm_from_vendor_curve(0.2f, narrow)); // 555.56 ppm + EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(0.5f, narrow)); // above the threshold +} + +TEST(MicsMath, SaturatedSensorEndToEnd) { + // V_AO at VCC -> x = 0 -> ratio 0 -> top of the range, the case that used to read + // 0 ppm and made a saturated sensor look like clean air. + const float x = air_value_from_voltage(5.0f, 5.0f); + const float ratio = ratio_from_air_value(x, 1.0f); + + EXPECT_FLOAT_EQ(0.0f, ratio); + EXPECT_FLOAT_EQ(CO.max_ppm, ppm_from_vendor_curve(ratio, CO)); +} + +} // namespace esphome::mics_5524::testing diff --git a/tests/components/mics_5524/test.esp32-idf.yaml b/tests/components/mics_5524/test.esp32-idf.yaml new file mode 100644 index 00000000000..1028eb373bc --- /dev/null +++ b/tests/components/mics_5524/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + mics_pin: GPIO34 + +packages: + mics_5524: !include common.yaml diff --git a/tests/components/mics_5524/test.esp8266-ard.yaml b/tests/components/mics_5524/test.esp8266-ard.yaml new file mode 100644 index 00000000000..6383f1e139a --- /dev/null +++ b/tests/components/mics_5524/test.esp8266-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + mics_pin: A0 + +packages: + mics_5524: !include common.yaml diff --git a/tests/components/mics_5524/test.rp2040-ard.yaml b/tests/components/mics_5524/test.rp2040-ard.yaml new file mode 100644 index 00000000000..a5aff154eba --- /dev/null +++ b/tests/components/mics_5524/test.rp2040-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + mics_pin: GPIO26 + +packages: + mics_5524: !include common.yaml diff --git a/tests/components/mics_5524/validate.esp32-idf.yaml b/tests/components/mics_5524/validate.esp32-idf.yaml new file mode 100644 index 00000000000..9ec6154be60 --- /dev/null +++ b/tests/components/mics_5524/validate.esp32-idf.yaml @@ -0,0 +1,23 @@ +# Config-only test: the external voltage sampler path and the PPM range options. +sensor: + - platform: adc + id: mics_5524_adc + name: MiCS-5524 AO + pin: GPIO35 + + - platform: mics_5524 + id: mics_5524_module + voltage: mics_5524_adc + voltage_multiplier: 1.5 + air_reference: 3.6 + vcc: 5.0 + sample_count: 2 + warmup_time: 30s + carbon_monoxide: + name: MiCS-5524 CO + max_ppm: 1000.0 + hydrogen: + name: MiCS-5524 H2 + ammonia: + name: MiCS-5524 NH3 + max_ppm: 300.0