[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 <nick@home-assistant.io>
This commit is contained in:
Nicolas Liaudat
2026-10-16 09:14:06 -10:00
committed by GitHub
co-authored by pre-commit-ci-lite[bot] J. Nick Koston
parent 38db8c1554
commit 9440012413
18 changed files with 993 additions and 0 deletions
+1
View File
@@ -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
+19
View File
@@ -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"
@@ -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),
}
+206
View File
@@ -0,0 +1,206 @@
#include "mics_5524.h"
#include <algorithm>
#include <cinttypes>
#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<unsigned>(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<float>(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<uint32_t>((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<float>(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
+104
View File
@@ -0,0 +1,104 @@
#pragma once
#include <cmath>
#include <cstdint>
#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<MiCS5524GasSensor *, MICS_5524_GAS_COUNT> gases_;
#endif
uint8_t samples_{4};
bool calibrating_{false};
};
} // namespace esphome::mics_5524
+59
View File
@@ -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 <cmath>
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
+153
View File
@@ -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],
)
+1
View File
@@ -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
@@ -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
@@ -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
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <gtest/gtest.h>
#include <cmath>
#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
+22
View File
@@ -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
@@ -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
@@ -0,0 +1,5 @@
substitutions:
mics_pin: GPIO34
packages:
mics_5524: !include common.yaml
@@ -0,0 +1,5 @@
substitutions:
mics_pin: A0
packages:
mics_5524: !include common.yaml
@@ -0,0 +1,5 @@
substitutions:
mics_pin: GPIO26
packages:
mics_5524: !include common.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